| 1 | import { Suspense, lazy } from "react"; |
| 2 | import { type ShouldRevalidateFunctionArgs, data } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/code-overview"; |
| 5 | import { page } from "../../lib/meta"; |
| 6 | import { loadMissionControl } from "../../lib/mission-control.server"; |
| 7 | import { requireUser, roleIn } from "../../lib/session.server"; |
| 8 | import { Card } from "../../components/ui/card"; |
| 9 | import { Skeleton, SkeletonCard, SkeletonLine, SkeletonStat } from "../../components/ui/skeleton"; |
| 10 | |
| 11 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 12 | return page(args, { title: `Overview · Code · ${params.owner} · g1t` }); |
| 13 | } |
| 14 | |
| 15 | export function headers({ loaderHeaders }: Route.HeadersArgs) { |
| 16 | const out = new Headers(); |
| 17 | const timing = loaderHeaders.get("Server-Timing"); |
| 18 | if (timing) out.set("Server-Timing", timing); |
| 19 | return out; |
| 20 | } |
| 21 | |
| 22 | /** |
| 23 | * Code's Overview, for this workspace: what needs you and what landed, |
| 24 | * the week's numbers, activity, and a way to open an issue. |
| 25 | */ |
| 26 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 27 | const viewer = requireUser(context, request); |
| 28 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 29 | const { value, timing } = await loadMissionControl(viewer, request, params.owner); |
| 30 | return data(value, { headers: { "Server-Timing": timing } }); |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * Switching tabs, the sort or the activity list changes only the address: |
| 35 | * everything is already loaded. Marking an inbox item read changes nothing |
| 36 | * else here. |
| 37 | */ |
| 38 | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { |
| 39 | if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false; |
| 40 | if (formAction === "/notifications") return false; |
| 41 | return defaultShouldRevalidate; |
| 42 | } |
| 43 | |
| 44 | /** Loaded once the browser is idle (components/shell.tsx), so the skeleton below is rarely seen. */ |
| 45 | const MissionControl = lazy(() => import("../../components/mission-control")); |
| 46 | |
| 47 | export default function CodeOverview({ loaderData }: Route.ComponentProps) { |
| 48 | return ( |
| 49 | <Suspense fallback={<MissionControlSkeleton />}> |
| 50 | <MissionControl loaderData={loaderData} /> |
| 51 | </Suspense> |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * The Overview's outline while its code arrives, piece for piece as |
| 57 | * components/mission-control.tsx lays it out: the title, the actions |
| 58 | * under it, the stats, then the work list beside the week. Each |
| 59 | * piece has the padding and line heights of the real one, so nothing moves |
| 60 | * when it arrives. |
| 61 | */ |
| 62 | export function MissionControlSkeleton() { |
| 63 | return ( |
| 64 | <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> |
| 65 | <span role="status" className="sr-only"> |
| 66 | Loading… |
| 67 | </span> |
| 68 | <header> |
| 69 | <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" /> |
| 70 | {/* The date and the week's summary: one line wide, three on a phone. */} |
| 71 | <div className="mt-1.5 text-sm"> |
| 72 | <SkeletonLine className="w-xl max-w-full" barClassName="h-3.5" /> |
| 73 | <SkeletonLine className="w-full sm:hidden" barClassName="h-3.5" /> |
| 74 | <SkeletonLine className="w-2/3 sm:hidden" barClassName="h-3.5" /> |
| 75 | </div> |
| 76 | </header> |
| 77 | <div className="flex flex-wrap items-center gap-2"> |
| 78 | <Skeleton className="h-9.5 w-28 rounded-md" /> |
| 79 | <Skeleton className="h-9.5 w-44 rounded-md" /> |
| 80 | </div> |
| 81 | <Card tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4"> |
| 82 | {Array.from({ length: 4 }, (_, index) => ( |
| 83 | <SkeletonStat key={index} /> |
| 84 | ))} |
| 85 | </Card> |
| 86 | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]"> |
| 87 | <Card className="self-start overflow-hidden"> |
| 88 | <div className="flex items-center gap-4 border-b border-line px-4 py-3 text-sm sm:px-5"> |
| 89 | <SkeletonLine className="w-24" /> |
| 90 | <SkeletonLine className="w-32" /> |
| 91 | <SkeletonLine className="hidden w-28 sm:flex" /> |
| 92 | </div> |
| 93 | <div className="divide-y divide-line"> |
| 94 | {Array.from({ length: 5 }, (_, index) => ( |
| 95 | <div key={index}> |
| 96 | <div className="flex items-center gap-3 px-4 py-3 sm:px-5"> |
| 97 | <Skeleton className="size-8 shrink-0 rounded-md" /> |
| 98 | <span className="min-w-0 grow"> |
| 99 | <SkeletonLine className="text-sm" width={`${70 - ((index * 17) % 30)}%`} /> |
| 100 | <SkeletonLine className="mt-0.5 text-xs" width={`${50 - ((index * 11) % 20)}%`} /> |
| 101 | </span> |
| 102 | <Skeleton className="hidden h-4 w-24 shrink-0 sm:block" /> |
| 103 | </div> |
| 104 | {/* The first row stands open, as the real list's does. */} |
| 105 | {index === 0 && ( |
| 106 | <div className="px-4 pb-4 sm:px-5 sm:pb-5"> |
| 107 | <Skeleton className="h-68 rounded-xl" /> |
| 108 | <Skeleton className="mt-3 h-8 w-44 rounded-md" /> |
| 109 | </div> |
| 110 | )} |
| 111 | </div> |
| 112 | ))} |
| 113 | </div> |
| 114 | </Card> |
| 115 | <div className="space-y-6"> |
| 116 | <SkeletonCard lines={0} className="h-80 p-5" /> |
| 117 | <SkeletonCard lines={6} className="p-5" /> |
| 118 | </div> |
| 119 | </div> |
| 120 | </main> |
| 121 | ); |
| 122 | } |