| 1 | /** |
| 2 | * The header every artifact's page shares, whatever its kind |
| 3 | * (docs/ARTIFACTS_MODE.md section 6.5): where it 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 { Link, useRevalidator } from "react-router"; |
| 12 | |
| 13 | import { spacePath } from "../../lib/folios"; |
| 14 | import { Hint } from "../ui/hint"; |
| 15 | import { foliosRequest } from "./actions"; |
| 16 | import { FolioGlyph } from "./kinds"; |
| 17 | import { FolioMenu } from "./menu"; |
| 18 | import { Crumbs, Face } from "./parts"; |
| 19 | import type { LiveStatus } from "./provider"; |
| 20 | import { ShareDialog } from "./share-dialog"; |
| 21 | |
| 22 | /** Someone here now, as the live document's awareness says. */ |
| 23 | export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean }; |
| 24 | |
| 25 | const ICON_BUTTON = "flex size-8 shrink-0 items-center justify-center rounded-md max-md:size-10"; |
| 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 | <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 | } |