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.

149 linesCodeBlameRaw
1/**
2 * Each kind of artifact as the site shows it: its icon and colour, whether
3 * it can be made yet, and its page body. One line per kind, so each kind's
4 * phase adds its own (slides, design and dashboards are "Coming soon"
5 * until theirs ships). Bodies are lazy, so
6 * Home and the sidebar carry no editor code.
7 */
8import { FOLIO_KIND_LABELS, type DocRole, type Folio, type FolioKind, type FolioPage, type FolioPreview } from "@g1t/contracts";
9import { FileText, LayoutDashboard, type LucideIcon, PenTool, Presentation } from "lucide-react";
10import { type ComponentType, type LazyExoticComponent, lazy } from "react";
11
12import type { FolioProvider } from "./provider";
13import type { Presence } from "./shell";
14
15/**
16 * What a kind's page body is given: the folio's page and the folio as it
17 * is now, its live connection (opened by the page as it hydrates, before
18 * the body's code arrives), the viewer's role, and the header's comment
19 * toggle; it tells the header who is here and what changed live.
20 */
21export type FolioBodyProps = {
22 slug: string;
23 page: FolioPage;
24 folio: Folio;
25 /** The folio's live document and presence; null until the page hydrates. */
26 live: FolioProvider | null;
27 role: DocRole;
28 showComments: boolean;
29 onPresence: (people: Presence[]) => void;
30 /** A rename or another change to the folio, from the room. */
31 onFolio: (folio: Folio) => void;
32 /** The viewer's role changed; null: their access ended. */
33 onRole: (role: DocRole | null) => void;
34 onError: (message: string) => void;
35};
36
37export type FolioKindUi = {
38 label: string;
39 icon: LucideIcon;
40 /** Tailwind classes for the kind's icon on its soft square. */
41 tone: string;
42 /** False: its tile says "Coming soon" and nothing makes one yet. */
43 ready: boolean;
44 beta?: boolean;
45 /** Can be edited on a phone (docs can; a design canvas can't). */
46 mobileEditable: boolean;
47 Body: LazyExoticComponent<ComponentType<FolioBodyProps>> | null;
48};
49
50/**
51 * Starts fetching the doc editor's code (BlockNote, Yjs, the blocks) in
52 * the browser, so it is here by the time it is wanted: as a doc's page
53 * hydrates, together with its body rather than after it, and when a
54 * link to a doc is hovered or focused. Nothing is fetched twice.
55 */
56let editorWarmed = false;
57export function warmDocEditor(): void {
58 if (editorWarmed || import.meta.env.SSR) return;
59 editorWarmed = true;
60 void import("./doc/editor").catch(() => {
61 editorWarmed = false;
62 });
63}
64
65export const FOLIO_KIND_UI: Record<FolioKind, FolioKindUi> = {
66 doc: {
67 label: FOLIO_KIND_LABELS.doc,
68 icon: FileText,
69 tone: "text-info bg-info/12",
70 ready: true,
71 mobileEditable: true,
72 Body: lazy(() => {
73 warmDocEditor();
74 return import("./doc/body");
75 }),
76 },
77 slides: { label: FOLIO_KIND_LABELS.slides, icon: Presentation, tone: "text-warn bg-warn/12", ready: false, mobileEditable: false, Body: null },
78 design: { label: FOLIO_KIND_LABELS.design, icon: PenTool, tone: "text-merged bg-merged/12", ready: false, mobileEditable: false, Body: null },
79 dashboard: {
80 label: FOLIO_KIND_LABELS.dashboard,
81 icon: LayoutDashboard,
82 tone: "text-success bg-success/12",
83 ready: false,
84 beta: true,
85 mobileEditable: false,
86 Body: null,
87 },
88};
89
90/** A kind's icon in its colour on a soft square, as list rows and tiles show it. */
91export function KindIcon({ kind, size = 16, box = 28 }: { kind: FolioKind; size?: number; box?: number }) {
92 const ui = FOLIO_KIND_UI[kind];
93 const Icon = ui.icon;
94 return (
95 <span className={`flex shrink-0 items-center justify-center rounded-md ${ui.tone}`} style={{ width: box, height: box }} aria-hidden="true">
96 <Icon size={size} />
97 </span>
98 );
99}
100
101/** A folio's own icon: its emoji, or its kind's icon in its colour (no square). */
102export function FolioGlyph({ folio, size = 15 }: { folio: { kind: FolioKind; icon: string | null }; size?: number }) {
103 if (folio.icon) {
104 return (
105 <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">
106 {folio.icon}
107 </span>
108 );
109 }
110 const ui = FOLIO_KIND_UI[folio.kind];
111 const Icon = ui.icon;
112 return <Icon size={size} className={ui.tone.split(" ")[0]} aria-hidden="true" />;
113}
114
115/**
116 * A card's picture, drawn from `preview` (written when the folio is
117 * saved; never data values): a doc's first lines, a deck's first slide, a
118 * design's frame, a dashboard's tile boxes.
119 */
120export function FolioThumbnail({ folio }: { folio: Pick<Folio, "kind" | "preview" | "excerpt" | "title"> }) {
121 const preview: FolioPreview | null = folio.preview;
122 if (preview?.kind === "doc" || (!preview && folio.kind === "doc")) {
123 const lines = preview?.kind === "doc" ? preview.lines : folio.excerpt ? [folio.excerpt] : [];
124 return (
125 <div className="h-full overflow-hidden bg-bg px-4 pt-4 text-[0.6875rem] leading-relaxed text-muted">
126 <p className="mb-1.5 line-clamp-1 text-xs font-semibold text-fg-soft">{folio.title || "Untitled"}</p>
127 {lines.length ? (
128 lines.slice(0, 6).map((line, i) => (
129 <p key={i} className="line-clamp-2">
130 {line}
131 </p>
132 ))
133 ) : (
134 <div className="space-y-1.5 pt-1" aria-hidden="true">
135 <div className="h-1.5 w-11/12 rounded bg-line" />
136 <div className="h-1.5 w-4/5 rounded bg-line" />
137 <div className="h-1.5 w-2/3 rounded bg-line" />
138 </div>
139 )}
140 </div>
141 );
142 }
143 // The other kinds draw their own once they ship; until then, the kind's mark.
144 return (
145 <div className="flex h-full items-center justify-center bg-bg">
146 <KindIcon kind={folio.kind} size={22} box={44} />
147 </div>
148 );
149}