Skip to content
141 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: { label: FOLIO_KIND_LABELS.dashboard, icon: LayoutDashboard, tone: "text-success bg-success/12", ready: false, beta: true, mobileEditable: false, Body: null },
80};
81
82/** A kind's icon in its colour on a soft square, as list rows and tiles show it. */
83export function KindIcon({ kind, size = 16, box = 28 }: { kind: FolioKind; size?: number; box?: number }) {
84 const ui = FOLIO_KIND_UI[kind];
85 const Icon = ui.icon;
86 return (
87 <span className={`flex shrink-0 items-center justify-center rounded-md ${ui.tone}`} style={{ width: box, height: box }} aria-hidden="true">
88 <Icon size={size} />
89 </span>
90 );
91}
92
93/** A folio's own icon: its emoji, or its kind's icon in its colour (no square). */
94export function FolioGlyph({ folio, size = 15 }: { folio: { kind: FolioKind; icon: string | null }; size?: number }) {
95 if (folio.icon) {
96 return (
97 <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">
98 {folio.icon}
99 </span>
100 );
101 }
102 const ui = FOLIO_KIND_UI[folio.kind];
103 const Icon = ui.icon;
104 return <Icon size={size} className={ui.tone.split(" ")[0]} aria-hidden="true" />;
105}
106
107/**
108 * A card's picture, drawn from `preview` (written when the folio is
109 * saved; never data values): a doc's first lines, a deck's first slide, a
110 * design's frame, a dashboard's tile boxes.
111 */
112export function FolioThumbnail({ folio }: { folio: Pick<Folio, "kind" | "preview" | "excerpt" | "title"> }) {
113 const preview: FolioPreview | null = folio.preview;
114 if (preview?.kind === "doc" || (!preview && folio.kind === "doc")) {
115 const lines = preview?.kind === "doc" ? preview.lines : folio.excerpt ? [folio.excerpt] : [];
116 return (
117 <div className="h-full overflow-hidden bg-bg px-4 pt-4 text-[0.6875rem] leading-relaxed text-muted">
118 <p className="mb-1.5 line-clamp-1 text-xs font-semibold text-fg-soft">{folio.title || "Untitled"}</p>
119 {lines.length ? (
120 lines.slice(0, 6).map((line, i) => (
121 <p key={i} className="line-clamp-2">
122 {line}
123 </p>
124 ))
125 ) : (
126 <div className="space-y-1.5 pt-1" aria-hidden="true">
127 <div className="h-1.5 w-11/12 rounded bg-line" />
128 <div className="h-1.5 w-4/5 rounded bg-line" />
129 <div className="h-1.5 w-2/3 rounded bg-line" />
130 </div>
131 )}
132 </div>
133 );
134 }
135 // The other kinds draw their own once they ship; until then, the kind's mark.
136 return (
137 <div className="flex h-full items-center justify-center bg-bg">
138 <KindIcon kind={folio.kind} size={22} box={44} />
139 </div>
140 );
141}