Skip to content
140 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 to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent">
55 {folio.icon && <span className="mr-1.5">{folio.icon}</span>}
56 {folio.title || "Untitled"}
57 </Link>
58 {folio.stale && (
59 <Hint label="Possibly out of date: code it cites changed">
60 <span className="relative size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
61 </Hint>
62 )}
63 </span>
64 <span className="relative hidden shrink-0 sm:flex">
65 <AccessMark folio={folio} />
66 </span>
67 <span className="hidden w-32 shrink-0 truncate text-xs text-faint md:block">{placeOf(folio, me)}</span>
68 <span className="relative w-28 shrink-0 text-right max-sm:w-auto">
69 <Edited folio={folio} />
70 </span>
71 <span className="relative">
72 <FolioMenu slug={slug} folio={folio} onError={onError} />
73 </span>
74 </li>
75 );
76}
77
78/** Rows under Today, Yesterday, then dates. */
79export function FolioDays({ slug, items, zone, onError }: { slug: string; items: Folio[]; zone: string; onError?: (message: string) => void }) {
80 const days = useMemo(() => dayGroups(items, (f) => f.edited_at, zone), [items, zone]);
81 return (
82 <div className="space-y-6">
83 {days.map((day) => (
84 <section key={day.key} aria-label={day.label}>
85 <h3 className="mb-1.5 px-1 text-xs font-medium text-faint">{day.label}</h3>
86 <Card asChild divided className="overflow-hidden">
87 <ul>
88 {day.items.map((f) => (
89 <FolioRow key={f.id} slug={slug} folio={f} onError={onError} />
90 ))}
91 </ul>
92 </Card>
93 </section>
94 ))}
95 </div>
96 );
97}
98
99export function FolioCard({ slug, folio, onError }: { slug: string; folio: Folio; onError?: (message: string) => void }) {
100 const me = useFoliosData()?.me.key;
101 return (
102 <Card className="group relative flex flex-col overflow-hidden transition-colors hover:border-line-strong">
103 <div className="pointer-events-none h-32 shrink-0 border-b border-line">
104 <FolioThumbnail folio={folio} />
105 </div>
106 <div className="flex items-start gap-2.5 px-3.5 pt-3 pb-3">
107 <KindIcon kind={folio.kind} size={14} box={24} />
108 <span className="min-w-0 grow">
109 <Link to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent">
110 {folio.icon && <span className="mr-1">{folio.icon}</span>}
111 {folio.title || "Untitled"}
112 </Link>
113 <span className="mt-0.5 flex items-center gap-1.5 text-[0.6875rem] text-faint">
114 <span className="truncate">{placeOf(folio, me)}</span>
115 <span aria-hidden="true">·</span>
116 <span className="relative">
117 <Edited folio={folio} />
118 </span>
119 </span>
120 </span>
121 <span className="relative -mt-1 -mr-1.5 flex items-center">
122 <span className="relative">
123 <AccessMark folio={folio} />
124 </span>
125 <FolioMenu slug={slug} folio={folio} onError={onError} />
126 </span>
127 </div>
128 </Card>
129 );
130}
131
132export function FolioGrid({ slug, items, onError }: { slug: string; items: Folio[]; onError?: (message: string) => void }) {
133 return (
134 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
135 {items.map((f) => (
136 <FolioCard key={f.id} slug={slug} folio={f} onError={onError} />
137 ))}
138 </div>
139 );
140}