Skip to content
140 linesCodeBlameRaw
1/**
2 * The header every artifact's page shares, whatever its kind: where it
3 * is (a space or Private,
4 * then the docs it sits under), who is here, "Offline, changes will
5 * sync", and Share, comments, history, favorite and the ⋯ menu. A kind's
6 * own actions go in `actions`.
7 */
8import type { Folio, FolioRef } from "@g1t/contracts";
9import { History, MessageSquare, Share2, Star, WifiOff } from "lucide-react";
10import { type ReactNode, useState } from "react";
11import { useRevalidator } from "react-router";
12
13import { spacePath } from "../../lib/folios";
14import { ButtonLink } from "../ui";
15import { Button } from "../ui/button";
16import { Hint } from "../ui/hint";
17import { foliosRequest } from "./actions";
18import { FolioGlyph } from "./kinds";
19import { FolioMenu } from "./menu";
20import { Crumbs, Face } from "./parts";
21import type { LiveStatus } from "./provider";
22import { ShareDialog } from "./share-dialog";
23
24/** Someone here now, as the live document's awareness says. */
25export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
26
27export function FolioHeader({
28 slug,
29 folio,
30 breadcrumbs,
31 presence,
32 status,
33 comments,
34 onError,
35 onTrashed,
36 actions,
37}: {
38 slug: string;
39 folio: Folio;
40 breadcrumbs: FolioRef[];
41 presence: Presence[];
42 status: LiveStatus;
43 /** The comments toggle, for kinds that have comments. */
44 comments?: { open: boolean; onToggle: () => void } | null;
45 onError: (message: string) => void;
46 onTrashed: () => void;
47 actions?: ReactNode;
48}) {
49 const { revalidate } = useRevalidator();
50 const [sharing, setSharing] = useState(false);
51 const [favorite, setFavorite] = useState(folio.favorite);
52 const others = presence.filter((p) => !p.me);
53 const crumbs = [
54 folio.space ? { label: folio.space.name, to: spacePath(slug, folio.space.slug) } : { label: "Private", to: `/${slug}/-/artifacts?space=private` },
55 ...breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })),
56 {
57 label: (
58 <span className="inline-flex items-center gap-1.5">
59 <FolioGlyph folio={folio} size={13} />
60 {folio.title || "Untitled"}
61 </span>
62 ),
63 },
64 ];
65 return (
66 // Right under the top bar, edge to edge, and one block with it (data-page-head in components/shell.tsx).
67 <div data-page-head="sticky" className="sticky top-(--topbar-h) z-20 -mx-4 flex h-14 items-center gap-2 border-b border-line bg-bg/90 px-4 backdrop-blur sm:-mx-6 sm:px-5 lg:-mx-8">
68 <div className="min-w-0 grow">
69 <Crumbs items={crumbs} />
70 </div>
71 {status === "offline" ? (
72 <Hint label="Your changes are kept and sync when the connection is back.">
73 <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0} data-live={status}>
74 <WifiOff size={13} /> <span className="max-sm:hidden">Offline, changes will sync</span>
75 </span>
76 </Hint>
77 ) : (
78 // A quiet dot: green once the room is in step with this page, faint
79 // until then. The page is readable and editable either way; what is
80 // typed before the room answers goes to it then (provider.ts).
81 <Hint label={status === "synced" ? "Live: everyone's changes show as they happen." : status === "closed" ? "No longer live." : "Connecting to the live document…"}>
82 <span className="flex size-8 shrink-0 items-center justify-center" tabIndex={0} aria-label={status === "synced" ? "Live" : status === "closed" ? "Not live" : "Connecting"} data-live={status}>
83 <span className={`size-2 rounded-full ${status === "synced" ? "bg-success" : status === "closed" ? "bg-line" : "bg-faint/50 animate-pulse"}`} aria-hidden="true" />
84 </span>
85 </Hint>
86 )}
87 {others.length > 0 && (
88 <span className="flex items-center -space-x-1.5 max-sm:hidden" aria-label={`${others.length} others here`}>
89 {others.slice(0, 5).map((p) => (
90 <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
91 <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
92 <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
93 </span>
94 </Hint>
95 ))}
96 {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
97 </span>
98 )}
99 {actions}
100 <Button type="button" onClick={() => setSharing(true)} variant="outline" size="sm" className="text-xs text-fg/85 hover:bg-raised max-md:size-10 max-md:justify-center max-md:px-0 font-normal" aria-label="Share">
101 <Share2 size={13} /> <span className="max-md:hidden">Share</span>
102 </Button>
103 {comments && (
104 <Hint label={comments.open ? "Hide comments" : "Comments"}>
105 <Button type="button" onClick={comments.onToggle} aria-pressed={comments.open} aria-label="Comments" variant="ghost" size="icon-sm" className="text-faint max-md:size-10">
106 <MessageSquare size={16} />
107 </Button>
108 </Hint>
109 )}
110 <Hint label="History">
111 <ButtonLink to={`${folio.path}/history`} aria-label="History" variant="ghost" size="icon-sm" className="text-faint max-md:size-10 max-sm:hidden">
112 <History size={16} />
113 </ButtonLink>
114 </Hint>
115 <Hint label={favorite ? "Remove from Favorites" : "Add to Favorites"}>
116 <Button
117 type="button"
118 aria-label={favorite ? "Remove from Favorites" : "Add to Favorites"}
119 aria-pressed={favorite}
120 onClick={async () => {
121 setFavorite(!favorite);
122 const done = await foliosRequest(slug, "favorite", { folio_id: folio.id, on: !favorite });
123 if (done.ok) void revalidate();
124 else {
125 setFavorite(favorite);
126 onError(done.error.message);
127 }
128 }}
129 variant="ghost"
130 size="icon-sm"
131 className="text-faint max-md:size-10 max-sm:hidden aria-pressed:bg-transparent aria-pressed:text-faint"
132 >
133 <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
134 </Button>
135 </Hint>
136 <FolioMenu slug={slug} folio={{ ...folio, favorite }} page onError={onError} onTrashed={onTrashed} />
137 <ShareDialog slug={slug} folio={folio} open={sharing} onOpenChange={setSharing} onChanged={() => void revalidate()} />
138 </div>
139 );
140}