| 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; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| canEdit?: boolean; |
| |
| isPublishing?: boolean; |
| |
| publishingUserName?: string | null; |
| onToggleTheme: () => void; |
| onOpenSettings: () => void; |
| onOpenPublish: () => void; |
| onOpenMobileToc: () => void; |
| |
| |
| |
| |
| onOpenEmbedStudio: () => void; |
| } |
|
|
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| 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]); |
|
|
| |
| |
| |
| 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 { |
| |
| |
| await fetch("/api/auth/logout", { |
| method: "POST", |
| credentials: "same-origin", |
| }); |
| } catch { |
| |
| |
| |
| |
| } |
| |
| |
| |
| 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> |
| ); |
| } |
|
|