Spaces:
Runtime error
Runtime error
| import { useEffect, useRef, useState } from "react"; | |
| import type { Editor as TiptapEditor } from "@tiptap/core"; | |
| import type { HocuspocusProvider } from "@hocuspocus/provider"; | |
| import { | |
| Undo2, | |
| Redo2, | |
| Settings, | |
| Upload, | |
| Eye, | |
| BarChart3, | |
| Sun, | |
| Moon, | |
| Menu, | |
| LogIn, | |
| LogOut, | |
| MoreHorizontal, | |
| FileText, | |
| Trash2, | |
| PencilLine, | |
| Lock, | |
| } from "lucide-react"; | |
| import { Tooltip } from "./Tooltip"; | |
| import { SyncIndicator } from "./SyncIndicator"; | |
| import { ConnectedUsers } from "./ConnectedUsers"; | |
| import type { CollabUser } from "../utils/user"; | |
| interface TopBarProps { | |
| editorInstance: TiptapEditor | null; | |
| providerRef: { current: HocuspocusProvider | null }; | |
| docName: string; | |
| theme: "light" | "dark"; | |
| user: CollabUser; | |
| loginUrl: string | null; | |
| isAuthenticated: boolean; | |
| /** | |
| * True when the signed-in user has write access on the backing Space | |
| * (Space owner, or org member with a `write`/`admin` role, or | |
| * `contributor` with at least one write resource group). When false | |
| * the editor is effectively read-only: the backend rejects mutating | |
| * routes with 403, so we mirror that on the UI by surfacing a | |
| * "Read-only" badge and disabling the Publish action up front. | |
| * Defaults to true in callers so the UI is permissive until the | |
| * status endpoint answers. | |
| */ | |
| canEdit?: boolean; | |
| /** True while any collaborator has a publish in progress. */ | |
| isPublishing?: boolean; | |
| /** Name of the user who initiated the running publish, if known. */ | |
| publishingUserName?: string | null; | |
| onToggleTheme: () => void; | |
| onOpenSettings: () => void; | |
| onOpenPublish: () => void; | |
| onOpenMobileToc: () => void; | |
| /** | |
| * Open the Embed Studio from the toolbar. Smart entry point: lands on | |
| * an existing chart when there is one, otherwise creates a new chart. | |
| */ | |
| onOpenEmbedStudio: () => void; | |
| } | |
| /** | |
| * Top toolbar of the editor: TOC drawer button (mobile only via CSS), | |
| * undo/redo, article settings, preview, theme toggle, publish action, sync | |
| * indicator, and the current user chip / sign-in link. | |
| */ | |
| export function TopBar({ | |
| editorInstance, | |
| providerRef, | |
| docName, | |
| theme, | |
| user, | |
| loginUrl, | |
| isAuthenticated, | |
| canEdit = true, | |
| isPublishing = false, | |
| publishingUserName = null, | |
| onToggleTheme, | |
| onOpenSettings, | |
| onOpenPublish, | |
| onOpenMobileToc, | |
| onOpenEmbedStudio, | |
| }: TopBarProps) { | |
| const publishTooltip = !canEdit | |
| ? "Read-only access - you don't have write rights on this Space" | |
| : isPublishing | |
| ? publishingUserName | |
| ? `${publishingUserName} is publishing...` | |
| : "Publish in progress..." | |
| : "Publish article"; | |
| const accessTooltip = canEdit | |
| ? "Editor access - you can write to this Space" | |
| : "Read-only access - ask an org admin to grant you a write role"; | |
| // Dev / utility menu state (Load demo, Reset article). Kept out of | |
| // the slash menu so it doesn't clutter the authoring surface. | |
| const [devMenuOpen, setDevMenuOpen] = useState(false); | |
| const devMenuRef = useRef<HTMLDivElement | null>(null); | |
| useEffect(() => { | |
| if (!devMenuOpen) return; | |
| const onDocClick = (e: MouseEvent) => { | |
| if (!devMenuRef.current) return; | |
| if (!devMenuRef.current.contains(e.target as Node)) setDevMenuOpen(false); | |
| }; | |
| const onKey = (e: KeyboardEvent) => { | |
| if (e.key === "Escape") setDevMenuOpen(false); | |
| }; | |
| document.addEventListener("mousedown", onDocClick); | |
| document.addEventListener("keydown", onKey); | |
| return () => { | |
| document.removeEventListener("mousedown", onDocClick); | |
| document.removeEventListener("keydown", onKey); | |
| }; | |
| }, [devMenuOpen]); | |
| // Account dropdown on the user chip (Sign out). | |
| // Same outside-click + Escape behavior as devMenu, but with its | |
| // own ref so the two menus don't toggle each other. | |
| const [accountMenuOpen, setAccountMenuOpen] = useState(false); | |
| const accountMenuRef = useRef<HTMLDivElement | null>(null); | |
| useEffect(() => { | |
| if (!accountMenuOpen) return; | |
| const onDocClick = (e: MouseEvent) => { | |
| if (!accountMenuRef.current) return; | |
| if (!accountMenuRef.current.contains(e.target as Node)) | |
| setAccountMenuOpen(false); | |
| }; | |
| const onKey = (e: KeyboardEvent) => { | |
| if (e.key === "Escape") setAccountMenuOpen(false); | |
| }; | |
| document.addEventListener("mousedown", onDocClick); | |
| document.addEventListener("keydown", onKey); | |
| return () => { | |
| document.removeEventListener("mousedown", onDocClick); | |
| document.removeEventListener("keydown", onKey); | |
| }; | |
| }, [accountMenuOpen]); | |
| const handleSignOut = async () => { | |
| setAccountMenuOpen(false); | |
| try { | |
| // POST (not GET) so this can't be triggered by a cross-site | |
| // <img src=...> trick. Backend clears the httpOnly cookie. | |
| await fetch("/api/auth/logout", { | |
| method: "POST", | |
| credentials: "same-origin", | |
| }); | |
| } catch { | |
| // Best-effort: even if the network call fails, reloading the | |
| // page lands us back on the login screen if the cookie was | |
| // already cleared, or keeps the session if it wasn't. The user | |
| // can retry from there. | |
| } | |
| // Hard reload so the WS provider, agent chat sessions, and any | |
| // cached auth state are reset cleanly. A SPA-only state purge | |
| // would leave the HocuspocusProvider connected with stale auth. | |
| window.location.reload(); | |
| }; | |
| const handleLoadDemo = () => { | |
| setDevMenuOpen(false); | |
| window.dispatchEvent(new CustomEvent("load-demo-content")); | |
| }; | |
| const handleReset = () => { | |
| setDevMenuOpen(false); | |
| if ( | |
| window.confirm( | |
| "Reset the article? This wipes the document, banner, frontmatter and citations.", | |
| ) | |
| ) { | |
| window.dispatchEvent(new CustomEvent("reset-article")); | |
| } | |
| }; | |
| return ( | |
| <div className="top-bar"> | |
| <button | |
| className="icon-btn top-bar__toc-toggle" | |
| onClick={onOpenMobileToc} | |
| aria-label="Table of contents" | |
| > | |
| <Menu size={18} /> | |
| </button> | |
| <Tooltip title="Undo"> | |
| <button | |
| className="icon-btn" | |
| onClick={() => editorInstance?.commands.undo()} | |
| aria-label="Undo" | |
| > | |
| <Undo2 size={18} /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip title="Redo"> | |
| <button | |
| className="icon-btn" | |
| onClick={() => editorInstance?.commands.redo()} | |
| aria-label="Redo" | |
| > | |
| <Redo2 size={18} /> | |
| </button> | |
| </Tooltip> | |
| <span className="divider-v" /> | |
| <Tooltip title="Article settings"> | |
| <button | |
| className="icon-btn" | |
| onClick={onOpenSettings} | |
| aria-label="Article settings" | |
| > | |
| <Settings size={18} /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip title="Embed Studio (charts)"> | |
| <button | |
| className="icon-btn" | |
| onClick={onOpenEmbedStudio} | |
| aria-label="Open Embed Studio" | |
| > | |
| <BarChart3 size={18} /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip title="Preview article"> | |
| <button | |
| className="icon-btn" | |
| onClick={() => window.open(`/api/preview/${docName}`, "_blank")} | |
| aria-label="Preview article" | |
| > | |
| <Eye size={18} /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}> | |
| <button | |
| className="icon-btn" | |
| onClick={onToggleTheme} | |
| aria-label="Toggle theme" | |
| > | |
| {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />} | |
| </button> | |
| </Tooltip> | |
| <div className="top-bar__menu" ref={devMenuRef}> | |
| <Tooltip title="More actions"> | |
| <button | |
| className="icon-btn" | |
| onClick={() => setDevMenuOpen((v) => !v)} | |
| aria-label="More actions" | |
| aria-haspopup="menu" | |
| aria-expanded={devMenuOpen || undefined} | |
| > | |
| <MoreHorizontal size={18} /> | |
| </button> | |
| </Tooltip> | |
| {devMenuOpen && ( | |
| <div className="top-bar__menu-pop" role="menu"> | |
| <button | |
| className="top-bar__menu-item" | |
| role="menuitem" | |
| onClick={handleLoadDemo} | |
| > | |
| <FileText size={14} /> | |
| <span className="top-bar__menu-item-content"> | |
| <span className="top-bar__menu-item-title"> | |
| Load demo content | |
| </span> | |
| <span className="top-bar__menu-item-desc"> | |
| Replace document with a full demo article | |
| </span> | |
| </span> | |
| </button> | |
| <button | |
| className="top-bar__menu-item top-bar__menu-item--danger" | |
| role="menuitem" | |
| onClick={handleReset} | |
| > | |
| <Trash2 size={14} /> | |
| <span className="top-bar__menu-item-content"> | |
| <span className="top-bar__menu-item-title">Reset article</span> | |
| <span className="top-bar__menu-item-desc"> | |
| Wipe document, banner, frontmatter, citations | |
| </span> | |
| </span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <span className="divider-v" /> | |
| <Tooltip title={publishTooltip}> | |
| <button | |
| className="icon-btn icon-btn--primary" | |
| onClick={onOpenPublish} | |
| aria-label="Publish article" | |
| disabled={isPublishing || !canEdit} | |
| aria-busy={isPublishing || undefined} | |
| > | |
| <Upload size={18} /> | |
| </button> | |
| </Tooltip> | |
| <ConnectedUsers providerRef={providerRef} /> | |
| <SyncIndicator editorInstance={editorInstance} providerRef={providerRef} /> | |
| {loginUrl && !isAuthenticated ? ( | |
| <a | |
| href={loginUrl} | |
| className="chip chip--login" | |
| style={{ marginLeft: 4, textDecoration: "none" }} | |
| > | |
| <LogIn size={14} /> | |
| Sign in with HF | |
| </a> | |
| ) : ( | |
| <> | |
| {isAuthenticated && ( | |
| <Tooltip title={accessTooltip}> | |
| <span | |
| className={`chip chip--sm ${canEdit ? "chip--editor" : "chip--readonly"}`} | |
| style={{ marginLeft: 4 }} | |
| aria-label={canEdit ? "Editor access" : "Read-only access"} | |
| > | |
| {canEdit ? <PencilLine size={11} /> : <Lock size={11} />} | |
| {canEdit ? "Editor" : "Read-only"} | |
| </span> | |
| </Tooltip> | |
| )} | |
| <div | |
| className="top-bar__menu" | |
| ref={accountMenuRef} | |
| style={{ marginLeft: 4 }} | |
| > | |
| <button | |
| type="button" | |
| className="chip chip--clickable" | |
| style={{ | |
| backgroundColor: user.color, | |
| color: "#000", | |
| border: "none", | |
| cursor: "pointer", | |
| }} | |
| onClick={() => setAccountMenuOpen((v) => !v)} | |
| aria-haspopup="menu" | |
| aria-expanded={accountMenuOpen || undefined} | |
| aria-label={`Signed in as ${user.name}. Open account menu.`} | |
| title={`Signed in as ${user.name}`} | |
| > | |
| {user.avatarUrl && <img src={user.avatarUrl} alt="" />} | |
| {user.name} | |
| </button> | |
| {accountMenuOpen && ( | |
| <div className="top-bar__menu-pop" role="menu"> | |
| <button | |
| className="top-bar__menu-item top-bar__menu-item--danger" | |
| role="menuitem" | |
| onClick={handleSignOut} | |
| > | |
| <LogOut size={14} /> | |
| <span className="top-bar__menu-item-content"> | |
| <span className="top-bar__menu-item-title">Sign out</span> | |
| <span className="top-bar__menu-item-desc"> | |
| Disconnect from Hugging Face on this device | |
| </span> | |
| </span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |