Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

381 linesCodeBlameRaw
1import { FOLIO_KINDS, isFolioKind, type Folio, type FolioKind, type FolioList } from "@g1t/contracts";
2import { ChevronDown, LayoutGrid, LayoutTemplate, List, Search, SlidersHorizontal, Users } from "lucide-react";
3import { type ReactNode, useEffect, useRef, useState } from "react";
4import { Form, Link, data, useNavigation, useSearchParams } from "react-router";
5
6import type { Route } from "./+types/home";
7
8import { foliosQuery, useFoliosData, useViewerZone } from "../../../components/folios/actions";
9import { FOLIO_KIND_UI, KindIcon } from "../../../components/folios/kinds";
10import { FolioDays, FolioGrid } from "../../../components/folios/list";
11import { FoliosSidebar } from "../../../components/folios/sidebar";
12import { BottomSheet } from "../../../components/mobile";
13import { EmptyState, ErrorText } from "../../../components/ui";
14import { Button } from "../../../components/ui/button";
15import { Card } from "../../../components/ui/card";
16import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../../components/ui/dialog";
17import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
18import { Hint } from "../../../components/ui/hint";
19import { SelectField } from "../../../components/ui/select";
20import { TabStrip } from "../../../components/ui/tab-strip";
21import { canDo, listQuery } from "../../../lib/folios";
22import { page } from "../../../lib/meta";
23import { folios, identity, repos } from "../../../lib/services.server";
24import { requireUser, roleIn } from "../../../lib/session.server";
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `Artifacts · ${params.owner} · g1t` });
28}
29
30export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ list: FolioList | null; projects: string[] }> {
31 const viewer = requireUser(context, request);
32 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
33 const slug = params.owner.toLowerCase();
34 const search = new URL(request.url).searchParams;
35 const query = listQuery(search);
36 // The owner filter is a username in the address; the service keys people by id.
37 const ownerName = search.get("owner");
38 if (ownerName) {
39 const user = await identity.userByUsername(ownerName.toLowerCase()).catch(() => null);
40 query.owner = user ? `user:${user.id}` : "user:nobody";
41 }
42 const [list, projects] = await Promise.all([
43 folios
44 .list(slug, viewer, query)
45 .then((r) => (r.ok ? r.value : null))
46 .catch(() => null),
47 repos
48 .list(viewer, { namespace: slug })
49 .then((found) => found.filter((r) => !r.forkOf).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))
50 .catch(() => [] as string[]),
51 ]);
52 return { list, projects };
53}
54
55const TABS = [
56 { key: "all", label: "All" },
57 { key: "yours", label: "Yours" },
58 { key: "shared", label: "Shared with you" },
59] as const;
60
61const EMPTY: Record<string, { title: string; body: string }> = {
62 all: { title: "No artifacts yet", body: "Start a doc above. Everything you and your workspace make shows up here." },
63 yours: { title: "Nothing of yours yet", body: "What you make is yours, in Private until you share it or move it to a space." },
64 shared: { title: "Nothing shared with you yet", body: "When someone shares an artifact with you, it shows up here." },
65};
66
67/**
68 * Artifacts' home (docs.g1t.sh/guides/artifacts/, "Home"): search and
69 * filters, All / Yours / Shared with you, tiles to make something new,
70 * and everything you can open, grouped by the day it was last edited, or
71 * as cards.
72 */
73export default function FoliosHome({ loaderData, params }: Route.ComponentProps) {
74 const slug = params.owner.toLowerCase();
75 const { list, projects } = loaderData;
76 const layout = useFoliosData();
77 const zone = useViewerZone();
78 const [search, setSearch] = useSearchParams();
79 const tab = search.get("tab") === "yours" || search.get("tab") === "shared" ? search.get("tab")! : "all";
80 const view = search.get("view") === "grid" ? "grid" : "list";
81 const q = search.get("q") ?? "";
82 const filtered = ["kind", "space", "owner", "project"].some((k) => search.get(k));
83 const [items, setItems] = useState<Folio[]>(list?.items ?? []);
84 const [cursor, setCursor] = useState<string | null>(list?.next_cursor ?? null);
85 const [more, setMore] = useState(false);
86 const [error, setError] = useState<string | null>(null);
87 const [filtersOpen, setFiltersOpen] = useState(false);
88 const searchBox = useRef<HTMLInputElement>(null);
89 useEffect(() => {
90 setItems(list?.items ?? []);
91 setCursor(list?.next_cursor ?? null);
92 }, [list]);
93 // `/` goes to the search box, as everywhere a page has one.
94 useEffect(() => {
95 const onKey = (e: KeyboardEvent) => {
96 const target = e.target as HTMLElement | null;
97 if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || target?.closest("input, textarea, [contenteditable=true]")) return;
98 e.preventDefault();
99 searchBox.current?.focus();
100 };
101 window.addEventListener("keydown", onKey);
102 return () => window.removeEventListener("keydown", onKey);
103 }, []);
104 const href = (changes: Record<string, string | null>) => {
105 const next = new URLSearchParams(search);
106 for (const [k, v] of Object.entries(changes)) {
107 if (v) next.set(k, v);
108 else next.delete(k);
109 }
110 next.delete("cursor");
111 const s = next.toString();
112 return s ? `?${s}` : ".";
113 };
114 const set = (changes: Record<string, string | null>) =>
115 setSearch(
116 (was) => {
117 const next = new URLSearchParams(was);
118 for (const [k, v] of Object.entries(changes)) {
119 if (v) next.set(k, v);
120 else next.delete(k);
121 }
122 next.delete("cursor");
123 return next;
124 },
125 { preventScrollReset: true },
126 );
127 const showMore = async () => {
128 if (!cursor) return;
129 setMore(true);
130 const next = new URLSearchParams(search);
131 next.set("list", "1");
132 next.set("cursor", cursor);
133 const page = await foliosQuery<FolioList>(slug, Object.fromEntries(next));
134 setMore(false);
135 if (!page.ok) return setError(page.error.message);
136 setItems((was) => [...was, ...page.value.items.filter((f) => !was.some((w) => w.id === f.id))]);
137 setCursor(page.value.next_cursor);
138 };
139 const spaces = layout?.sidebar?.spaces ?? [];
140 const filters = (
141 <>
142 <SelectField
143 aria-label="Kind"
144 value={search.get("kind") ?? "any"}
145 onValueChange={(v) => set({ kind: v === "any" ? null : v })}
146 options={[{ value: "any", label: "Any kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]}
147 className="h-9 w-36 max-md:w-full"
148 />
149 <SelectField
150 aria-label="Space"
151 value={search.get("space") ?? "any"}
152 onValueChange={(v) => set({ space: v === "any" ? null : v })}
153 options={[{ value: "any", label: "Any space" }, { value: "private", label: "Private" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]}
154 className="h-9 w-36 max-md:w-full"
155 />
156 <SelectField
157 aria-label="Owner"
158 value={search.get("owner") ?? "any"}
159 onValueChange={(v) => set({ owner: v === "any" ? null : v })}
160 options={[{ value: "any", label: "Anyone" }, ...(layout?.mentionables ?? []).filter((m) => m.kind === "user").map((m) => ({ value: m.name, label: m.name === layout?.me.name ? "You" : m.display_name }))]}
161 className="h-9 w-36 max-md:w-full"
162 />
163 {projects.length > 0 && (
164 <SelectField
165 aria-label="Project"
166 value={search.get("project") ?? "any"}
167 onValueChange={(v) => set({ project: v === "any" ? null : v })}
168 options={[{ value: "any", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]}
169 className="h-9 w-44 max-md:w-full"
170 />
171 )}
172 </>
173 );
174 return (
175 <div className="mx-auto max-w-5xl">
176 <div className="flex flex-wrap items-center gap-3">
177 <h1 className="grow text-2xl font-semibold tracking-tight">Artifacts</h1>
178 <Hint label="Templates">
179 <Link to={`/${slug}/-/artifacts/templates`} aria-label="Templates" className="flex size-9 items-center justify-center rounded-md border border-line text-muted transition-colors hover:border-line-strong hover:bg-surface hover:text-fg">
180 <LayoutTemplate size={16} />
181 </Link>
182 </Hint>
183 </div>
184
185 <MakeTiles slug={slug} spaces={spaces.filter((s) => canDo(s.viewer_role, "edit")).map((s) => ({ id: s.id, name: s.name }))} />
186
187 <div className="mt-8 flex flex-wrap items-center gap-2">
188 <Form method="get" className="flex h-9 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60 sm:max-w-sm" onSubmit={(e) => (e.preventDefault(), set({ q: (new FormData(e.currentTarget).get("q") as string).trim() || null }))}>
189 <Search size={15} className="text-faint" aria-hidden="true" />
190 <input ref={searchBox} key={q} name="q" defaultValue={q} placeholder="Search artifacts" aria-label="Search artifacts" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
191 <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.625rem] text-faint sm:inline">/</kbd>
192 </Form>
193 <div className="hidden flex-wrap items-center gap-2 md:flex">{filters}</div>
194 <button type="button" onClick={() => setFiltersOpen(true)} className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm md:hidden ${filtered ? "border-accent/50 text-accent" : "border-line text-muted"}`}>
195 <SlidersHorizontal size={14} /> Filters
196 </button>
197 <div className="ml-auto hidden items-center rounded-md border border-line p-0.5 md:flex" role="group" aria-label="Show as">
198 <ViewButton active={view === "list"} label="List" to={href({ view: null })}>
199 <List size={15} />
200 </ViewButton>
201 <ViewButton active={view === "grid"} label="Grid" to={href({ view: "grid" })}>
202 <LayoutGrid size={15} />
203 </ViewButton>
204 </div>
205 </div>
206 <BottomSheet open={filtersOpen} onOpenChange={setFiltersOpen} title="Filters">
207 <div className="flex flex-col gap-2 px-3 pb-4 [&>*]:w-full">{filters}</div>
208 </BottomSheet>
209
210 <TabStrip label="Which artifacts" className="mt-5 gap-1 border-b border-line">
211 {TABS.map((t) => (
212 <Link
213 key={t.key}
214 to={href({ tab: t.key === "all" ? null : t.key })}
215 preventScrollReset
216 data-active={tab === t.key}
217 aria-current={tab === t.key ? "page" : undefined}
218 className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${tab === t.key ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"}`}
219 >
220 {t.key === "shared" && <Users size={14} />}
221 {t.label}
222 </Link>
223 ))}
224 </TabStrip>
225
226 {error && (
227 <div className="mt-3">
228 <ErrorText>{error}</ErrorText>
229 </div>
230 )}
231
232 <div className="mt-5 mb-8">
233 {!list ? (
234 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
235 ) : items.length === 0 ? (
236 q || filtered ? (
237 <EmptyState title="Nothing matches">
238 <Link to={href({ q: null, kind: null, space: null, owner: null, project: null })} className="text-accent hover:underline">
239 Clear the search and filters
240 </Link>
241 </EmptyState>
242 ) : (
243 <EmptyState title={EMPTY[tab]!.title}>{EMPTY[tab]!.body}</EmptyState>
244 )
245 ) : view === "grid" ? (
246 <>
247 <div className="hidden md:block">
248 <FolioGrid slug={slug} items={items} onError={setError} />
249 </div>
250 <div className="md:hidden">
251 <FolioDays slug={slug} items={items} zone={zone} onError={setError} />
252 </div>
253 </>
254 ) : (
255 <FolioDays slug={slug} items={items} zone={zone} onError={setError} />
256 )}
257 {cursor && (
258 <div className="mt-4 flex justify-center">
259 <Button type="button" onClick={showMore} disabled={more} variant="outline" className="px-4 text-muted disabled:opacity-60 font-normal">
260 {more ? "Loading…" : "Show more"}
261 </Button>
262 </div>
263 )}
264 </div>
265
266 {/* On a phone, the sidebar's places below the list: the mode's sheet has them too. */}
267 <div className="-mx-4 border-t border-line md:hidden">
268 <div className="h-[60dvh]">
269 <FoliosSidebar slug={slug} />
270 </div>
271 </div>
272 </div>
273 );
274}
275
276function ViewButton({ active, label, to, children }: { active: boolean; label: string; to: string; children: ReactNode }) {
277 return (
278 <Hint label={label}>
279 <Link to={to} preventScrollReset aria-label={label} aria-pressed={active} className={`flex size-7 items-center justify-center rounded ${active ? "bg-raised text-fg" : "text-faint hover:text-fg"}`}>
280 {children}
281 </Link>
282 </Hint>
283 );
284}
285
286/**
287 * "Make something new": a tile per kind. Docs makes one at once (a form
288 * post, so it can't go twice), its chevron starts from a template or in a
289 * space; the kinds still to come say so.
290 */
291function MakeTiles({ slug, spaces }: { slug: string; spaces: { id: string; name: string }[] }) {
292 const navigation = useNavigation();
293 const starting = navigation.state !== "idle" && navigation.formMethod?.toUpperCase() === "POST" && navigation.formAction?.includes("/-/artifacts/new/");
294 const [choosing, setChoosing] = useState(false);
295 return (
296 <div className="-mx-4 mt-5 flex snap-x gap-3 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid sm:grid-cols-4 sm:overflow-visible sm:px-0">
297 {FOLIO_KINDS.map((kind) => {
298 const ui = FOLIO_KIND_UI[kind];
299 const body = (
300 <>
301 <KindIcon kind={kind} size={18} box={36} />
302 <span className="mt-3 flex items-center gap-1.5 text-sm font-medium text-fg">
303 {ui.label}
304 {ui.beta && <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Beta</span>}
305 </span>
306 <span className="mt-0.5 text-xs text-faint">{ui.ready ? (starting ? "Starting…" : tileLine(kind)) : "Coming soon"}</span>
307 </>
308 );
309 if (!ui.ready) {
310 return (
311 <Hint key={kind} label={`${COMING[kind]} are coming soon.`}>
312 <Card tabIndex={0} aria-disabled="true" tone="plain" className="flex w-40 shrink-0 snap-start flex-col border-dashed bg-surface/40 p-4 opacity-60 sm:w-auto">
313 {body}
314 </Card>
315 </Hint>
316 );
317 }
318 return (
319 <div key={kind} className="relative flex w-40 shrink-0 snap-start sm:w-auto">
320 <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="flex grow">
321 <button type="submit" disabled={starting} aria-busy={starting} className="flex grow flex-col rounded-xl border border-line bg-surface p-4 text-left transition-colors hover:border-accent/50 hover:bg-raised/40 disabled:opacity-70">
322 {body}
323 </button>
324 </Form>
325 <DropdownMenu>
326 <DropdownMenuTrigger asChild>
327 <Button type="button" aria-label={`More ways to make ${ui.label.toLowerCase()}`} variant="ghost" size="icon-xs" className="absolute top-2 right-2 text-faint">
328 <ChevronDown size={14} />
329 </Button>
330 </DropdownMenuTrigger>
331 <DropdownMenuContent align="end" className="w-48">
332 <DropdownMenuItem asChild>
333 <Link to={`/${slug}/-/artifacts/templates?kind=${kind}`}>From a template…</Link>
334 </DropdownMenuItem>
335 <DropdownMenuItem disabled={spaces.length === 0} onSelect={() => setChoosing(true)}>
336 In a space…
337 </DropdownMenuItem>
338 </DropdownMenuContent>
339 </DropdownMenu>
340 <SpaceChooser slug={slug} kind={kind} spaces={spaces} open={choosing} onOpenChange={setChoosing} />
341 </div>
342 );
343 })}
344 </div>
345 );
346}
347
348/** What each kind is for, under its tile. */
349const TILE_LINES: Record<FolioKind, string> = { doc: "Notes, specs, decisions", slides: "Decks to present", design: "Screens and layouts", dashboard: "Numbers to track" };
350
351/** The kinds still to come, as the Hint names them. */
352const COMING: Record<FolioKind, string> = { doc: "Docs", slides: "Slides", design: "Designs", dashboard: "Dashboards" };
353
354function tileLine(kind: FolioKind): string {
355 return TILE_LINES[kind];
356}
357
358function SpaceChooser({ slug, kind, spaces, open, onOpenChange }: { slug: string; kind: FolioKind; spaces: { id: string; name: string }[]; open: boolean; onOpenChange: (o: boolean) => void }) {
359 const [space, setSpace] = useState(spaces[0]?.id ?? "");
360 const navigation = useNavigation();
361 const busy = navigation.state !== "idle";
362 if (!isFolioKind(kind)) return null;
363 return (
364 <Dialog open={open} onOpenChange={onOpenChange}>
365 <DialogContent>
366 <DialogHeader>
367 <DialogTitle>New {FOLIO_KIND_UI[kind].label.toLowerCase().replace(/s$/, "")} in a space</DialogTitle>
368 <DialogDescription>It follows the space&apos;s access: everyone in the space can open it.</DialogDescription>
369 </DialogHeader>
370 <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="space-y-4">
371 <SelectField name="space" aria-label="Space" value={space} onValueChange={setSpace} options={spaces.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" />
372 <div className="flex justify-end">
373 <Button type="submit" disabled={busy || !space} variant="accent" className="disabled:opacity-60">
374 {busy ? "Starting…" : "Make it"}
375 </Button>
376 </div>
377 </Form>
378 </DialogContent>
379 </Dialog>
380 );
381}