| 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 | */ |
| 8 | import { FOLIO_KIND_LABELS, type DocRole, type Folio, type FolioKind, type FolioPage, type FolioPreview } from "@g1t/contracts"; |
| 9 | import { FileText, LayoutDashboard, type LucideIcon, PenTool, Presentation } from "lucide-react"; |
| 10 | import { type ComponentType, type LazyExoticComponent, lazy } from "react"; |
| 11 | |
| 12 | import type { LiveStatus } from "./provider"; |
| 13 | import 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, the viewer's role, and the header's comment toggle; it tells the |
| 18 | * header who is here, how the connection is, and what changed live. |
| 19 | */ |
| 20 | export type FolioBodyProps = { |
| 21 | slug: string; |
| 22 | page: FolioPage; |
| 23 | folio: Folio; |
| 24 | role: DocRole; |
| 25 | showComments: boolean; |
| 26 | onPresence: (people: Presence[]) => void; |
| 27 | onStatus: (status: LiveStatus) => void; |
| 28 | /** A rename or another change to the folio, from the room. */ |
| 29 | onFolio: (folio: Folio) => void; |
| 30 | /** The viewer's role changed; null: their access ended. */ |
| 31 | onRole: (role: DocRole | null) => void; |
| 32 | onError: (message: string) => void; |
| 33 | }; |
| 34 | |
| 35 | export type FolioKindUi = { |
| 36 | label: string; |
| 37 | icon: LucideIcon; |
| 38 | /** Tailwind classes for the kind's icon on its soft square. */ |
| 39 | tone: string; |
| 40 | /** False: its tile says "Coming soon" and nothing makes one yet. */ |
| 41 | ready: boolean; |
| 42 | beta?: boolean; |
| 43 | /** Can be edited on a phone (docs can; a design canvas can't). */ |
| 44 | mobileEditable: boolean; |
| 45 | Body: LazyExoticComponent<ComponentType<FolioBodyProps>> | null; |
| 46 | }; |
| 47 | |
| 48 | export const FOLIO_KIND_UI: Record<FolioKind, FolioKindUi> = { |
| 49 | doc: { label: FOLIO_KIND_LABELS.doc, icon: FileText, tone: "text-info bg-info/12", ready: true, mobileEditable: true, Body: lazy(() => import("./doc/body")) }, |
| 50 | slides: { label: FOLIO_KIND_LABELS.slides, icon: Presentation, tone: "text-warn bg-warn/12", ready: false, mobileEditable: false, Body: null }, |
| 51 | design: { label: FOLIO_KIND_LABELS.design, icon: PenTool, tone: "text-merged bg-merged/12", ready: false, mobileEditable: false, Body: null }, |
| 52 | dashboard: { label: FOLIO_KIND_LABELS.dashboard, icon: LayoutDashboard, tone: "text-success bg-success/12", ready: false, beta: true, mobileEditable: false, Body: null }, |
| 53 | }; |
| 54 | |
| 55 | /** A kind's icon in its colour on a soft square, as list rows and tiles show it. */ |
| 56 | export function KindIcon({ kind, size = 16, box = 28 }: { kind: FolioKind; size?: number; box?: number }) { |
| 57 | const ui = FOLIO_KIND_UI[kind]; |
| 58 | const Icon = ui.icon; |
| 59 | return ( |
| 60 | <span className={`flex shrink-0 items-center justify-center rounded-md ${ui.tone}`} style={{ width: box, height: box }} aria-hidden="true"> |
| 61 | <Icon size={size} /> |
| 62 | </span> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | /** A folio's own icon: its emoji, or its kind's icon in its colour (no square). */ |
| 67 | export function FolioGlyph({ folio, size = 15 }: { folio: { kind: FolioKind; icon: string | null }; size?: number }) { |
| 68 | if (folio.icon) { |
| 69 | return ( |
| 70 | <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true"> |
| 71 | {folio.icon} |
| 72 | </span> |
| 73 | ); |
| 74 | } |
| 75 | const ui = FOLIO_KIND_UI[folio.kind]; |
| 76 | const Icon = ui.icon; |
| 77 | return <Icon size={size} className={ui.tone.split(" ")[0]} aria-hidden="true" />; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * A card's picture, drawn from `preview` (written when the folio is |
| 82 | * saved; never data values): a doc's first lines, a deck's first slide, a |
| 83 | * design's frame, a dashboard's tile boxes. |
| 84 | */ |
| 85 | export function FolioThumbnail({ folio }: { folio: Pick<Folio, "kind" | "preview" | "excerpt" | "title"> }) { |
| 86 | const preview: FolioPreview | null = folio.preview; |
| 87 | if (preview?.kind === "doc" || (!preview && folio.kind === "doc")) { |
| 88 | const lines = preview?.kind === "doc" ? preview.lines : folio.excerpt ? [folio.excerpt] : []; |
| 89 | return ( |
| 90 | <div className="h-full overflow-hidden bg-bg px-4 pt-4 text-[0.6875rem] leading-relaxed text-muted"> |
| 91 | <p className="mb-1.5 line-clamp-1 text-xs font-semibold text-fg-soft">{folio.title || "Untitled"}</p> |
| 92 | {lines.length ? ( |
| 93 | lines.slice(0, 6).map((line, i) => ( |
| 94 | <p key={i} className="line-clamp-2"> |
| 95 | {line} |
| 96 | </p> |
| 97 | )) |
| 98 | ) : ( |
| 99 | <div className="space-y-1.5 pt-1" aria-hidden="true"> |
| 100 | <div className="h-1.5 w-11/12 rounded bg-line" /> |
| 101 | <div className="h-1.5 w-4/5 rounded bg-line" /> |
| 102 | <div className="h-1.5 w-2/3 rounded bg-line" /> |
| 103 | </div> |
| 104 | )} |
| 105 | </div> |
| 106 | ); |
| 107 | } |
| 108 | // The other kinds draw their own once they ship; until then, the kind's mark. |
| 109 | return ( |
| 110 | <div className="flex h-full items-center justify-center bg-bg"> |
| 111 | <KindIcon kind={folio.kind} size={22} box={44} /> |
| 112 | </div> |
| 113 | ); |
| 114 | } |