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.

150 linesCodeBlameRaw
1/**
2 * Artifacts as lists show them: rows
3 * grouped by the day they were last edited, in the viewer's time zone, or
4 * cards with a picture drawn from each one's preview. Each has its kind,
5 * who can see it, its space, when it was edited and by whom, and its ⋯
6 * menu.
7 */
8import type { Folio } from "@g1t/contracts";
9import { useMemo } from "react";
10import { Link } from "react-router";
11
12import { dayGroups } from "../../lib/folios";
13import { TimeAgo } from "../ui";
14import { Card } from "../ui/card";
15import { Hint } from "../ui/hint";
16import { useFoliosData } from "./actions";
17import { FolioThumbnail, KindIcon, warmDocEditor } from "./kinds";
18
19/**
20 * On a link to a doc: as the link's own `prefetch="intent"` fetches the
21 * page's code and data on hover or focus, this fetches the editor's, so
22 * opening it is a navigation and nothing more.
23 */
24export function warmOnIntent(folio: Pick<Folio, "kind">): { onMouseEnter?: () => void; onFocus?: () => void; onTouchStart?: () => void } {
25 if (folio.kind !== "doc") return {};
26 return { onMouseEnter: warmDocEditor, onFocus: warmDocEditor, onTouchStart: warmDocEditor };
27}
28import { FolioMenu } from "./menu";
29import { AccessMark } from "./parts";
30
31/** Where it sits, in a word: its space; else your Private, or whose it is when it's someone else's. */
32function placeOf(folio: Folio, me: string | undefined): string {
33 if (folio.space) return folio.space.name;
34 return `user:${folio.owner.id}` === me ? "Private" : folio.owner.display_name;
35}
36
37function Edited({ folio }: { folio: Folio }) {
38 const by = folio.edited_by?.display_name;
39 return (
40 <Hint label={by ? `Edited by ${by}` : "Edited"}>
41 <span className="shrink-0 text-xs whitespace-nowrap text-faint" tabIndex={-1}>
42 Edited <TimeAgo at={folio.edited_at} />
43 </span>
44 </Hint>
45 );
46}
47
48export function FolioRow({ slug, folio, onError }: { slug: string; folio: Folio; onError?: (message: string) => void }) {
49 const me = useFoliosData()?.me.key;
50 return (
51 <li className="group relative flex items-center gap-3 px-3 py-2 transition-colors hover:bg-raised/60 sm:px-4">
52 <KindIcon kind={folio.kind} />
53 <span className="flex min-w-0 grow items-center gap-2">
54 <Link
55 to={folio.path}
56 prefetch="intent"
57 {...warmOnIntent(folio)}
58 className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent"
59 >
60 {folio.icon && <span className="mr-1.5">{folio.icon}</span>}
61 {folio.title || "Untitled"}
62 </Link>
63 {folio.stale && (
64 <Hint label="Possibly out of date: code it cites changed">
65 <span className="relative size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
66 </Hint>
67 )}
68 </span>
69 <span className="relative hidden shrink-0 sm:flex">
70 <AccessMark folio={folio} />
71 </span>
72 <span className="hidden w-32 shrink-0 truncate text-xs text-faint md:block">{placeOf(folio, me)}</span>
73 <span className="relative w-28 shrink-0 text-right max-sm:w-auto">
74 <Edited folio={folio} />
75 </span>
76 <span className="relative">
77 <FolioMenu slug={slug} folio={folio} onError={onError} />
78 </span>
79 </li>
80 );
81}
82
83/** Rows under Today, Yesterday, then dates. */
84export function FolioDays({ slug, items, zone, onError }: { slug: string; items: Folio[]; zone: string; onError?: (message: string) => void }) {
85 const days = useMemo(() => dayGroups(items, (f) => f.edited_at, zone), [items, zone]);
86 return (
87 <div className="space-y-6">
88 {days.map((day) => (
89 <section key={day.key} aria-label={day.label}>
90 <h3 className="mb-1.5 px-1 text-xs font-medium text-faint">{day.label}</h3>
91 <Card asChild divided className="overflow-hidden">
92 <ul>
93 {day.items.map((f) => (
94 <FolioRow key={f.id} slug={slug} folio={f} onError={onError} />
95 ))}
96 </ul>
97 </Card>
98 </section>
99 ))}
100 </div>
101 );
102}
103
104export function FolioCard({ slug, folio, onError }: { slug: string; folio: Folio; onError?: (message: string) => void }) {
105 const me = useFoliosData()?.me.key;
106 return (
107 <Card className="group relative flex flex-col overflow-hidden transition-colors hover:border-line-strong">
108 <div className="pointer-events-none h-32 shrink-0 border-b border-line">
109 <FolioThumbnail folio={folio} />
110 </div>
111 <div className="flex items-start gap-2.5 px-3.5 pt-3 pb-3">
112 <KindIcon kind={folio.kind} size={14} box={24} />
113 <span className="min-w-0 grow">
114 <Link
115 to={folio.path}
116 prefetch="intent"
117 {...warmOnIntent(folio)}
118 className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent"
119 >
120 {folio.icon && <span className="mr-1">{folio.icon}</span>}
121 {folio.title || "Untitled"}
122 </Link>
123 <span className="mt-0.5 flex items-center gap-1.5 text-[0.6875rem] text-faint">
124 <span className="truncate">{placeOf(folio, me)}</span>
125 <span aria-hidden="true">·</span>
126 <span className="relative">
127 <Edited folio={folio} />
128 </span>
129 </span>
130 </span>
131 <span className="relative -mt-1 -mr-1.5 flex items-center">
132 <span className="relative">
133 <AccessMark folio={folio} />
134 </span>
135 <FolioMenu slug={slug} folio={folio} onError={onError} />
136 </span>
137 </div>
138 </Card>
139 );
140}
141
142export function FolioGrid({ slug, items, onError }: { slug: string; items: Folio[]; onError?: (message: string) => void }) {
143 return (
144 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
145 {items.map((f) => (
146 <FolioCard key={f.id} slug={slug} folio={f} onError={onError} />
147 ))}
148 </div>
149 );
150}