tfrere's picture
tfrere HF Staff
feat(editor): Embed Studio button in the top bar
2dfb335
Raw
History Blame Contribute Delete
12 kB
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>
);
}