| ... | ... | @@ -8,16 +8,31 @@ |
| 8 | 8 | * number of bars costs one opacity animation. |
| 9 | 9 | */ |
| 10 | 10 | import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; |
| 11 | | import { ArrowLeftIcon } from "lucide-react"; |
| 11 | import { |
| 12 | ArrowLeftIcon, |
| 13 | ChevronRightIcon, |
| 14 | EllipsisIcon, |
| 15 | ExternalLinkIcon, |
| 16 | FileDiffIcon, |
| 17 | PanelRightIcon, |
| 18 | TagIcon, |
| 19 | UserPlusIcon, |
| 20 | UsersIcon, |
| 21 | } from "lucide-react"; |
| 12 | 22 | import type { ReactNode } from "react"; |
| 13 | 23 | |
| 24 | import { readLocalApi } from "~/localApi"; |
| 14 | 25 | import { cn } from "~/lib/utils"; |
| 15 | 26 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 16 | 27 | |
| 28 | import { Button, InlineButton } from "../ui/button"; |
| 29 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 30 | import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; |
| 17 | 31 | import { pullRequestLabelColor } from "./pullRequestList.logic"; |
| 18 | 32 | import { |
| 19 | 33 | PullRequestActorLabel, |
| 20 | 34 | PullRequestDiffStat, |
| 35 | PullRequestMetaLine, |
| 21 | 36 | pullRequestChecksStatePresentation, |
| 22 | 37 | resolvePullRequestState, |
| 23 | 38 | } from "./pullRequestPresentation"; |
| ... | ... | @@ -29,6 +44,11 @@ function GhostBar({ className }: { className?: string | undefined }) { |
| 29 | 44 | /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ |
| 30 | 45 | const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"]; |
| 31 | 46 | const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"]; |
| 47 | const DEFAULT_DETAIL_TABS = [ |
| 48 | { value: "summary", label: "Summary" }, |
| 49 | { value: "timeline", label: "Timeline" }, |
| 50 | { value: "code", label: "Code" }, |
| 51 | ] as const; |
| 32 | 52 | |
| 33 | 53 | /** Rows in the list's own grid — glyph, title over meta, time over diffstat. */ |
| 34 | 54 | export function PullRequestListGhost({ |
| ... | ... | @@ -77,10 +97,25 @@ export function PullRequestDetailGhost({ |
| 77 | 97 | seed: entry, |
| 78 | 98 | summary, |
| 79 | 99 | actions, |
| 100 | checkoutCommand, |
| 101 | tabs = DEFAULT_DETAIL_TABS, |
| 102 | activeTab, |
| 103 | number, |
| 104 | onBack, |
| 105 | onClose, |
| 106 | onCheckoutError, |
| 80 | 107 | }: { |
| 81 | 108 | seed?: PullRequestListEntry | null; |
| 82 | 109 | summary?: PullRequestSummary | null; |
| 83 | 110 | actions?: ReactNode; |
| 111 | checkoutCommand?: string | null; |
| 112 | tabs?: ReadonlyArray<{ value: string; label: string }>; |
| 113 | /** The panel's current tab, so the highlight does not jump when the detail arrives. */ |
| 114 | activeTab?: string; |
| 115 | number?: number; |
| 116 | onBack?: (() => void) | undefined; |
| 117 | onClose?: (() => void) | undefined; |
| 118 | onCheckoutError?: ((error: Error) => void) | undefined; |
| 84 | 119 | }) { |
| 85 | 120 | const seed = summary |
| 86 | 121 | ? { |
| ... | ... | @@ -100,6 +135,10 @@ export function PullRequestDetailGhost({ |
| 100 | 135 | seed?.checksState === "failing" || seed?.checksState === "pending" |
| 101 | 136 | ? pullRequestChecksStatePresentation(seed.checksState) |
| 102 | 137 | : null; |
| 138 | const checkout = checkoutCommand ?? null; |
| 139 | const changedFiles = summary?.changedFiles ?? null; |
| 140 | const selectedTab = |
| 141 | tabs.find((item) => item.value === activeTab)?.value ?? tabs[0]?.value ?? "summary"; |
| 103 | 142 | |
| 104 | 143 | return ( |
| 105 | 144 | <div |
| ... | ... | @@ -110,100 +149,178 @@ export function PullRequestDetailGhost({ |
| 110 | 149 | !seed && "motion-safe:animate-skeleton", |
| 111 | 150 | )} |
| 112 | 151 | > |
| 113 | | <div className="@container/pr-header shrink-0 border-b border-border/60"> |
| 114 | | <div className="flex h-7 items-center justify-between gap-3 px-4"> |
| 115 | | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 116 | | {seed && statePresentation ? ( |
| 152 | <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> |
| 153 | <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden"> |
| 154 | <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground sm:text-xs"> |
| 155 | {onBack ? ( |
| 156 | <Button |
| 157 | size="icon-micro" |
| 158 | variant="ghost-muted" |
| 159 | className="-ml-1.5" |
| 160 | onClick={onBack} |
| 161 | aria-label="Back to this thread's pull requests" |
| 162 | > |
| 163 | <ArrowLeftIcon aria-hidden className="size-3.5" /> |
| 164 | </Button> |
| 165 | ) : null} |
| 166 | {seed ? ( |
| 117 | 167 | <> |
| 118 | | <span className="min-w-0 truncate text-xs font-medium text-muted-foreground"> |
| 119 | | {seed.repository} |
| 120 | | </span> |
| 121 | | <span |
| 122 | | className={cn("shrink-0 text-xs font-medium", statePresentation.toneClassName)} |
| 168 | <span className="min-w-0 truncate font-medium">{seed.repository}</span> |
| 169 | <InlineButton |
| 170 | onClick={() => void readLocalApi()?.shell.openExternal(seed.url)} |
| 171 | className={cn( |
| 172 | "font-medium underline-offset-2 hover:underline", |
| 173 | statePresentation?.toneClassName, |
| 174 | )} |
| 175 | aria-label={`Open pull request #${seed.number} on host`} |
| 123 | 176 | > |
| 124 | 177 | #{seed.number} |
| 125 | | </span> |
| 178 | <ExternalLinkIcon aria-hidden className="size-2.5" /> |
| 179 | </InlineButton> |
| 126 | 180 | </> |
| 127 | 181 | ) : ( |
| 128 | 182 | <> |
| 129 | 183 | <GhostBar className="w-24" /> |
| 130 | | <GhostBar className="w-9" /> |
| 184 | <span className="shrink-0 text-xs text-muted-foreground">#{number ?? "…"}</span> |
| 131 | 185 | </> |
| 132 | 186 | )} |
| 133 | 187 | </div> |
| 134 | | <div className="flex shrink-0 items-center gap-1"> |
| 135 | | {actions ?? <GhostBar className="h-5 w-16 rounded-md" />} |
| 136 | | <GhostBar className="size-6 rounded-md" /> |
| 137 | | </div> |
| 188 | </div> |
| 189 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> |
| 190 | {actions ?? <GhostBar className="h-6 w-16 rounded-md" />} |
| 191 | <Button size="icon-xs" variant="ghost" disabled aria-label="Pull request actions loading"> |
| 192 | <EllipsisIcon aria-hidden className="size-4" /> |
| 193 | </Button> |
| 194 | {onClose ? ( |
| 195 | <Button |
| 196 | size="icon-xs" |
| 197 | variant="ghost" |
| 198 | aria-label="Collapse pull request panel" |
| 199 | onClick={onClose} |
| 200 | > |
| 201 | <PanelRightIcon aria-hidden className="size-3.5" /> |
| 202 | </Button> |
| 203 | ) : null} |
| 138 | 204 | </div> |
| 139 | 205 | |
| 140 | | <div className="px-4 pb-4 pt-1"> |
| 141 | | {seed ? ( |
| 142 | | <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1> |
| 143 | | ) : ( |
| 144 | | <GhostBar className="h-5 w-4/5 max-w-md" /> |
| 145 | | )} |
| 146 | | <div className="mt-2 flex items-center gap-1.5"> |
| 147 | | {seed ? ( |
| 148 | | <> |
| 149 | | <PullRequestActorLabel |
| 150 | | actor={seed.author ?? null} |
| 151 | | className="font-medium" |
| 152 | | tooltip={false} |
| 153 | | /> |
| 154 | | <span className="text-xs text-muted-foreground"> |
| 155 | | updated {formatRelativeTimeLabel(seed.updatedAt)} |
| 156 | | </span> |
| 157 | | </> |
| 158 | | ) : ( |
| 159 | | <> |
| 160 | | <GhostBar className="size-4 rounded-full" /> |
| 161 | | <GhostBar className="w-24" /> |
| 162 | | </> |
| 163 | | )} |
| 164 | | </div> |
| 165 | | <div className="mt-4 flex min-w-0 items-center gap-2"> |
| 166 | | {seed ? ( |
| 167 | | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"> |
| 168 | | <code className="min-w-0 max-w-[40%] shrink-0 truncate">{seed.baseBranch}</code> |
| 169 | | <ArrowLeftIcon |
| 170 | | aria-label="receives changes from" |
| 171 | | className="size-3.5 shrink-0 opacity-60" |
| 172 | | /> |
| 173 | | <code className="min-w-0 flex-1 truncate">{seed.headBranch}</code> |
| 174 | | </span> |
| 175 | | ) : ( |
| 176 | | <> |
| 177 | | <GhostBar className="h-6 w-24 rounded-md" /> |
| 178 | | <GhostBar className="size-3 rounded-full" /> |
| 179 | | <GhostBar className="h-6 w-32 rounded-md" /> |
| 180 | | </> |
| 181 | | )} |
| 182 | | <div className="ml-auto flex shrink-0 items-center gap-2"> |
| 183 | | <GhostBar className="w-10" /> |
| 206 | <div className="col-span-2 grid grid-rows-[1fr]"> |
| 207 | <div className="min-h-0 overflow-hidden"> |
| 208 | <div className="col-span-2 mt-1 min-w-0 px-4 pb-4"> |
| 184 | 209 | {seed ? ( |
| 185 | | <PullRequestDiffStat |
| 186 | | additions={seed.additions ?? 0} |
| 187 | | deletions={seed.deletions ?? 0} |
| 188 | | className="font-mono text-xs" |
| 189 | | /> |
| 210 | <div className="flex min-h-7 min-w-0 items-center sm:min-h-6"> |
| 211 | <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1> |
| 212 | </div> |
| 190 | 213 | ) : ( |
| 191 | | <GhostBar className="w-20" /> |
| 214 | <div className="flex min-h-7 min-w-0 items-center sm:min-h-6"> |
| 215 | <GhostBar className="h-5 w-4/5 max-w-md" /> |
| 216 | </div> |
| 192 | 217 | )} |
| 218 | <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 219 | {seed ? ( |
| 220 | <PullRequestMetaLine className="min-w-0 whitespace-nowrap"> |
| 221 | <PullRequestActorLabel |
| 222 | actor={seed.author ?? null} |
| 223 | className="font-medium" |
| 224 | tooltip={false} |
| 225 | /> |
| 226 | <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span> |
| 227 | </PullRequestMetaLine> |
| 228 | ) : ( |
| 229 | <PullRequestMetaLine className="min-w-0 whitespace-nowrap"> |
| 230 | <span className="flex items-center gap-1.5"> |
| 231 | <GhostBar className="size-4 rounded-full" /> |
| 232 | <GhostBar className="h-3 w-14" /> |
| 233 | </span> |
| 234 | <GhostBar className="h-3 w-16" /> |
| 235 | </PullRequestMetaLine> |
| 236 | )} |
| 237 | {checkout ? ( |
| 238 | <PullRequestCopyableCode |
| 239 | key={checkout} |
| 240 | value={checkout} |
| 241 | target="pull request checkout command" |
| 242 | copyLabel="Copy checkout command" |
| 243 | copiedLabel="Checkout command copied" |
| 244 | className="ml-auto font-mono" |
| 245 | tooltipSide="bottom" |
| 246 | {...(onCheckoutError ? { onError: onCheckoutError } : {})} |
| 247 | /> |
| 248 | ) : null} |
| 249 | </div> |
| 250 | |
| 251 | <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 252 | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"> |
| 253 | {seed ? ( |
| 254 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 255 | <code className="min-w-0 truncate">{seed.baseBranch}</code> |
| 256 | </span> |
| 257 | ) : ( |
| 258 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 259 | <GhostBar className="h-3 w-12" /> |
| 260 | </span> |
| 261 | )} |
| 262 | <ArrowLeftIcon |
| 263 | aria-label="receives changes from" |
| 264 | className="size-3.5 shrink-0 opacity-60" |
| 265 | /> |
| 266 | {seed ? ( |
| 267 | <PullRequestCopyableCode |
| 268 | key={seed.headBranch} |
| 269 | value={seed.headBranch} |
| 270 | target="branch name" |
| 271 | copyLabel="Copy pull request branch" |
| 272 | copiedLabel="Branch name copied" |
| 273 | className="min-w-0 font-mono" |
| 274 | /> |
| 275 | ) : ( |
| 276 | <GhostBar className="h-3 w-32 flex-1" /> |
| 277 | )} |
| 278 | </span> |
| 279 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2"> |
| 280 | <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums"> |
| 281 | <FileDiffIcon aria-hidden className="size-3.5" /> |
| 282 | {changedFiles === null ? ( |
| 283 | <GhostBar className="h-3 w-10" /> |
| 284 | ) : ( |
| 285 | `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` |
| 286 | )} |
| 287 | </span> |
| 288 | {seed ? ( |
| 289 | <PullRequestDiffStat |
| 290 | additions={seed.additions ?? 0} |
| 291 | deletions={seed.deletions ?? 0} |
| 292 | className="shrink-0 font-mono text-xs" |
| 293 | /> |
| 294 | ) : ( |
| 295 | <GhostBar className="h-3 w-20" /> |
| 296 | )} |
| 297 | </span> |
| 298 | </div> |
| 193 | 299 | </div> |
| 194 | 300 | </div> |
| 195 | 301 | </div> |
| 196 | 302 | |
| 197 | | <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2"> |
| 198 | | <div className="flex items-center gap-1 p-0.5"> |
| 199 | | <GhostBar className="h-6 w-16 rounded-md" /> |
| 200 | | <GhostBar className="h-6 w-16 rounded-md" /> |
| 201 | | <GhostBar className="h-6 w-12 rounded-md" /> |
| 202 | | </div> |
| 303 | <nav |
| 304 | className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2" |
| 305 | aria-label="Pull request tabs" |
| 306 | inert |
| 307 | > |
| 308 | <ToggleGroup |
| 309 | className="shrink-0" |
| 310 | size="segmented" |
| 311 | variant="segmented" |
| 312 | value={[selectedTab]} |
| 313 | > |
| 314 | {tabs.map((tab) => ( |
| 315 | <Toggle key={tab.value} value={tab.value} tabIndex={-1}> |
| 316 | {tab.label} |
| 317 | </Toggle> |
| 318 | ))} |
| 319 | </ToggleGroup> |
| 203 | 320 | {checksPresentation ? ( |
| 204 | 321 | <span |
| 205 | 322 | className={cn( |
| 206 | | "inline-flex items-center gap-1.5 text-xs", |
| 323 | "ml-auto inline-flex items-center gap-1.5 text-xs", |
| 207 | 324 | checksPresentation.toneClassName, |
| 208 | 325 | )} |
| 209 | 326 | > |
| ... | ... | @@ -211,74 +328,85 @@ export function PullRequestDetailGhost({ |
| 211 | 328 | {checksPresentation.label} |
| 212 | 329 | </span> |
| 213 | 330 | ) : ( |
| 214 | | <GhostBar className="w-20" /> |
| 331 | <GhostBar className="ml-auto h-3 w-32" /> |
| 215 | 332 | )} |
| 216 | | </div> |
| 333 | </nav> |
| 217 | 334 | </div> |
| 218 | 335 | |
| 219 | 336 | <div className="min-h-0 flex-1 overflow-hidden"> |
| 220 | | <section className="px-4 py-3"> |
| 221 | | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> |
| 222 | | <div className="flex items-center gap-1.5"> |
| 223 | | <GhostBar className="size-3.5 rounded-full" /> |
| 224 | | <GhostBar className="w-14" /> |
| 225 | | </div> |
| 226 | | <div className="flex items-center gap-1"> |
| 227 | | <GhostBar className="size-4 rounded-full" /> |
| 228 | | <GhostBar className="size-4 rounded-full" /> |
| 229 | | <GhostBar className="ml-1 size-5 rounded-md" /> |
| 230 | | </div> |
| 231 | | </div> |
| 232 | | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> |
| 233 | | <div className="flex items-center gap-1.5"> |
| 234 | | <GhostBar className="size-3.5 rounded-full" /> |
| 235 | | <GhostBar className="w-10" /> |
| 236 | | </div> |
| 237 | | <div className="flex items-center gap-1"> |
| 238 | | {seed?.labels ? ( |
| 239 | | seed.labels.slice(0, 3).map((label) => { |
| 240 | | const color = pullRequestLabelColor(label.color); |
| 241 | | return ( |
| 242 | | <span |
| 243 | | key={label.name} |
| 244 | | className="inline-flex h-5 max-w-32 items-center gap-1 truncate rounded-full border border-border/70 bg-muted/40 px-2 text-[10px] text-muted-foreground" |
| 245 | | > |
| 246 | | <span |
| 247 | | aria-hidden |
| 248 | | className="size-2 shrink-0 rounded-full bg-muted-foreground" |
| 249 | | {...(color ? { style: { backgroundColor: color } } : {})} |
| 250 | | /> |
| 251 | | <span className="truncate">{label.name}</span> |
| 252 | | </span> |
| 253 | | ); |
| 254 | | }) |
| 255 | | ) : ( |
| 256 | | <> |
| 257 | | <GhostBar className="h-5 w-24 rounded-full" /> |
| 258 | | <GhostBar className="h-5 w-20 rounded-full" /> |
| 259 | | </> |
| 260 | | )} |
| 337 | <section className="px-4 pt-2.5 pb-1"> |
| 338 | <div className="space-y-2"> |
| 339 | <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6"> |
| 340 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 341 | <UsersIcon aria-hidden className="size-3.5" /> |
| 342 | Reviewers |
| 343 | </span> |
| 344 | <span className="flex min-w-0 items-center gap-1.5 text-foreground"> |
| 345 | <GhostBar className="h-3 w-10" /> |
| 346 | <Button size="icon-xs" variant="ghost" disabled aria-label="Reviewers loading"> |
| 347 | <UserPlusIcon aria-hidden className="size-3.5" /> |
| 348 | </Button> |
| 349 | </span> |
| 261 | 350 | </div> |
| 262 | | </div> |
| 263 | | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2"> |
| 264 | | <div className="flex items-center gap-1.5"> |
| 265 | | <GhostBar className="size-3.5 rounded-full" /> |
| 266 | | <GhostBar className="w-14" /> |
| 351 | <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6"> |
| 352 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 353 | <TagIcon aria-hidden className="size-3.5" /> |
| 354 | Labels |
| 355 | </span> |
| 356 | <span className="flex min-w-0 flex-wrap items-center gap-1 text-foreground"> |
| 357 | {entry?.labels ? ( |
| 358 | entry.labels.length > 0 ? ( |
| 359 | entry.labels.map((label) => { |
| 360 | const color = pullRequestLabelColor(label.color); |
| 361 | return ( |
| 362 | <span |
| 363 | key={label.name} |
| 364 | className="inline-flex max-w-48 items-center gap-1.5 truncate rounded-full bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" |
| 365 | > |
| 366 | <span |
| 367 | aria-hidden |
| 368 | className="size-2 shrink-0 rounded-full bg-muted-foreground" |
| 369 | {...(color ? { style: { backgroundColor: color } } : {})} |
| 370 | /> |
| 371 | <span className="truncate">{label.name}</span> |
| 372 | </span> |
| 373 | ); |
| 374 | }) |
| 375 | ) : ( |
| 376 | <span className="text-muted-foreground">None</span> |
| 377 | ) |
| 378 | ) : ( |
| 379 | <> |
| 380 | <GhostBar className="h-4 w-24 rounded-full" /> |
| 381 | <GhostBar className="h-4 w-20 rounded-full" /> |
| 382 | </> |
| 383 | )} |
| 384 | <Button size="icon-xs" variant="ghost" disabled aria-label="Labels loading"> |
| 385 | <TagIcon aria-hidden className="size-3.5" /> |
| 386 | </Button> |
| 387 | </span> |
| 267 | 388 | </div> |
| 268 | | <GhostBar className="w-20" /> |
| 269 | 389 | </div> |
| 270 | 390 | </section> |
| 271 | 391 | |
| 272 | | <section className="border-t border-border/60"> |
| 273 | | <div className="flex min-h-11 items-center gap-1.5 px-4 py-3"> |
| 274 | | <GhostBar className="h-4 w-24" /> |
| 275 | | <GhostBar className="size-3.5 rounded-full" /> |
| 392 | <section> |
| 393 | <div className="sticky top-0 z-10 flex w-full items-center bg-background pr-4"> |
| 394 | <div className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-muted-foreground"> |
| 395 | <span>Description</span> |
| 396 | <ChevronRightIcon |
| 397 | aria-hidden |
| 398 | className="size-3.5 rotate-90 text-muted-foreground/60" |
| 399 | /> |
| 400 | </div> |
| 276 | 401 | </div> |
| 277 | | <div className="space-y-2 px-4 pb-4"> |
| 278 | | <GhostBar className="w-full" /> |
| 279 | | <GhostBar className="w-11/12" /> |
| 280 | | <GhostBar className="w-4/5" /> |
| 281 | | <GhostBar className="w-2/3" /> |
| 402 | <div className="space-y-3 px-4 pb-4"> |
| 403 | <GhostBar className="h-5 w-40" /> |
| 404 | <div className="space-y-2"> |
| 405 | <GhostBar className="h-4 w-full" /> |
| 406 | <GhostBar className="h-4 w-11/12" /> |
| 407 | <GhostBar className="h-4 w-4/5" /> |
| 408 | <GhostBar className="h-4 w-2/3" /> |
| 409 | </div> |
| 282 | 410 | </div> |
| 283 | 411 | </section> |
| 284 | 412 | </div> |