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(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(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 // 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 (
{devMenuOpen && (
)}
{loginUrl && !isAuthenticated ? ( Sign in with HF ) : ( <> {isAuthenticated && ( {canEdit ? : } {canEdit ? "Editor" : "Read-only"} )}
{accountMenuOpen && (
)}
)}
); }