Skip to content
128 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 { Link, useRevalidator } from "react-router";
12
13import { spacePath } from "../../lib/folios";
14import { Hint } from "../ui/hint";
15import { foliosRequest } from "./actions";
16import { FolioGlyph } from "./kinds";
17import { FolioMenu } from "./menu";
18import { Crumbs, Face } from "./parts";
19import type { LiveStatus } from "./provider";
20import { ShareDialog } from "./share-dialog";
21
22/** Someone here now, as the live document's awareness says. */
23export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
24
25const ICON_BUTTON = "flex size-8 shrink-0 items-center justify-center rounded-md max-md:size-10";
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 <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
67 <div className="min-w-0 grow">
68 <Crumbs items={crumbs} />
69 </div>
70 {status === "offline" && (
71 <Hint label="Your changes are kept and sync when the connection is back.">
72 <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0}>
73 <WifiOff size={13} /> <span className="max-sm:hidden">Offline, changes will sync</span>
74 </span>
75 </Hint>
76 )}
77 {others.length > 0 && (
78 <span className="flex items-center -space-x-1.5 max-sm:hidden" aria-label={`${others.length} others here`}>
79 {others.slice(0, 5).map((p) => (
80 <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
81 <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
82 <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
83 </span>
84 </Hint>
85 ))}
86 {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
87 </span>
88 )}
89 {actions}
90 <button type="button" onClick={() => setSharing(true)} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised max-md:size-10 max-md:justify-center max-md:px-0" aria-label="Share">
91 <Share2 size={13} /> <span className="max-md:hidden">Share</span>
92 </button>
93 {comments && (
94 <Hint label={comments.open ? "Hide comments" : "Comments"}>
95 <button type="button" onClick={comments.onToggle} aria-pressed={comments.open} aria-label="Comments" className={`${ICON_BUTTON} ${comments.open ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
96 <MessageSquare size={16} />
97 </button>
98 </Hint>
99 )}
100 <Hint label="History">
101 <Link to={`${folio.path}/history`} aria-label="History" className={`${ICON_BUTTON} text-faint hover:bg-raised hover:text-fg max-sm:hidden`}>
102 <History size={16} />
103 </Link>
104 </Hint>
105 <Hint label={favorite ? "Remove from Favorites" : "Add to Favorites"}>
106 <button
107 type="button"
108 aria-label={favorite ? "Remove from Favorites" : "Add to Favorites"}
109 aria-pressed={favorite}
110 onClick={async () => {
111 setFavorite(!favorite);
112 const done = await foliosRequest(slug, "favorite", { folio_id: folio.id, on: !favorite });
113 if (done.ok) void revalidate();
114 else {
115 setFavorite(favorite);
116 onError(done.error.message);
117 }
118 }}
119 className={`${ICON_BUTTON} text-faint hover:bg-raised hover:text-fg max-sm:hidden`}
120 >
121 <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
122 </button>
123 </Hint>
124 <FolioMenu slug={slug} folio={{ ...folio, favorite }} page onError={onError} onTrashed={onTrashed} />
125 <ShareDialog slug={slug} folio={folio} open={sharing} onOpenChange={setSharing} onChanged={() => void revalidate()} />
126 </div>
127 );
128}