g1t/apps/web/app/components/checklist.tsx

90 lines3,850 bytesCodeBlame
1import { Check, ChevronRight, X } from "lucide-react";
2import { useCallback, useSyncExternalStore } from "react";
3import { Link } from "react-router";
4
5import { type ChecklistItem, dismiss, isDismissed, progress } from "../lib/checklist";
6import { cn } from "../lib/cn";
7
8const storage = () => (typeof window === "undefined" ? null : window.localStorage);
9const listeners = new Set<() => void>();
10const subscribe = (listener: () => void) => {
11 listeners.add(listener);
12 return () => listeners.delete(listener);
13};
14
15/**
16 * The steps to production for one project, as `3/6`, until they are all
17 * done or someone dismisses it. Dismissing is per project and remembered by
18 * this browser.
19 */
20export function ProductionChecklist({ base, items }: { base: string; items: ChecklistItem[] }) {
21 const hidden = useSyncExternalStore(
22 subscribe,
23 useCallback(() => isDismissed(storage, base), [base]),
24 () => false,
25 );
26 const { done, total, complete } = progress(items);
27 if (hidden || complete) return null;
28 const next = items.find((item) => !item.done)?.key;
29
30 return (
31 <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface">
32 <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6">
33 <h2 id="checklist-title" className="text-sm font-semibold">
34 Get to production
35 </h2>
36 <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted">
37 {done}/{total}
38 </span>
39 <span className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-line sm:block" aria-hidden>
40 <span className="block h-full rounded-full bg-accent" style={{ width: `${(done / total) * 100}%` }} />
41 </span>
42 <button
43 type="button"
44 onClick={() => {
45 dismiss(storage, base);
46 for (const listener of listeners) listener();
47 }}
48 className="ml-auto rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
49 aria-label="Dismiss the checklist for this project"
50 title="Dismiss for this project"
51 >
52 <X size={14} />
53 </button>
54 </div>
55 <ol className="grid border-t border-line sm:grid-cols-2 lg:grid-cols-3">
56 {items.map((item) => (
57 <li key={item.key} className="border-b border-line last:border-b-0 sm:[&:nth-last-child(-n+2)]:border-b-0 lg:[&:nth-last-child(-n+3)]:border-b-0">
58 <Link
59 to={item.to}
60 className={cn(
61 "group flex h-full items-start gap-3 px-5 py-3 transition-colors hover:bg-raised/50 sm:px-6",
62 item.done && "opacity-70",
63 )}
64 >
65 <span
66 className={cn(
67 "mt-px flex size-4.5 shrink-0 items-center justify-center rounded-full border",
68 item.done ? "border-accent bg-accent text-bg" : item.key === next ? "border-accent" : "border-line-strong",
69 )}
70 aria-hidden
71 >
72 {item.done && <Check size={11} strokeWidth={3} />}
73 </span>
74 <span className="min-w-0 grow">
75 <span className={cn("block text-[0.8125rem] font-medium", item.done && "text-muted line-through decoration-line-strong")}>
76 {item.title}
77 <span className="sr-only">{item.done ? " (done)" : ""}</span>
78 </span>
79 {!item.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{item.detail}</span>}
80 </span>
81 {!item.done && (
82 <ChevronRight size={14} className="mt-0.5 shrink-0 text-faint transition-colors group-hover:text-fg" />
83 )}
84 </Link>
85 </li>
86 ))}
87 </ol>
88 </section>
89 );
90}