| 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 | */ |
| 8 | import type { Folio, FolioRef } from "@g1t/contracts"; |
| 9 | import { History, MessageSquare, Share2, Star, WifiOff } from "lucide-react"; |
| 10 | import { type ReactNode, useState } from "react"; |
| 11 | import { useRevalidator } from "react-router"; |
| 12 | |
| 13 | import { spacePath } from "../../lib/folios"; |
| 14 | import { ButtonLink } from "../ui"; |
| 15 | import { Button } from "../ui/button"; |
| 16 | import { Hint } from "../ui/hint"; |
| 17 | import { foliosRequest } from "./actions"; |
| 18 | import { FolioGlyph } from "./kinds"; |
| 19 | import { FolioMenu } from "./menu"; |
| 20 | import { Crumbs, Face } from "./parts"; |
| 21 | import type { LiveStatus } from "./provider"; |
| 22 | import { ShareDialog } from "./share-dialog"; |
| 23 | |
| 24 | /** Someone here now, as the live document's awareness says. */ |
| 25 | export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean }; |
| 26 | |
| 27 | export 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 | } |