import { useEffect, useRef, useState, type CSSProperties } from "react"; import { Check, FileText, Presentation, Settings } from "lucide-react"; import corePackage from "../../../package.json"; import { cn } from "../core/cn"; import type { HtmlPageBlock, ReaderDocument, Theme, WorkspaceManifest, WorkspaceManifestPress } from "../document-model"; import { PUBLIC_HTML_PAGE_CLASS, PUBLIC_HTML_PAGE_HTML_CLASS } from "../reader/publicViewerClasses"; import { Button } from "@/openpress/ui/button"; import { Skeleton } from "@/openpress/ui/skeleton"; import { WORKSPACE_ACCENT_OPTIONS, WORKSPACE_COLOR_MODE_OPTIONS, useWorkspaceAppearance, type WorkspaceAccent, type WorkspaceColorModePreference, } from "./workspaceAppearance"; import { HOTKEY_COMMANDS, type HotkeyCommand } from "../hotkeys"; import { WorkspaceUpdatesSettings } from "./WorkspaceUpdatesSettings"; import type { WorkspaceUpdatesInfo } from "./workspaceUpdatesModel"; type GalleryFilter = "all" | "pages" | "slides"; interface Props { manifest: WorkspaceManifest; view: "documents" | "settings"; onSelectPress: (press: WorkspaceManifestPress) => void; onOpenDocuments: () => void; onOpenSettings: () => void; } const GALLERY_CLASS = [ "op-workspace openpress-workspace-gallery m-0 flex min-h-screen flex-col gap-9 bg-[var(--op-workspace-bg)]", "px-[clamp(2rem,4vw,4.5rem)] pb-24 pt-[3.6rem] font-sans text-[var(--op-workspace-text)]", "[background:var(--op-workspace-gallery-bg)]", "max-[720px]:px-4 max-[720px]:pb-16 max-[720px]:pt-9", ].join(" "); const GALLERY_HEADER_CLASS = "openpress-workspace-gallery__header flex items-end justify-between gap-10 border-b border-[var(--op-workspace-border)] pb-[1.45rem]"; const GALLERY_HEADLINE_CLASS = "openpress-workspace-gallery__headline grid gap-3"; const GALLERY_BRAND_CLASS = "openpress-workspace-gallery__brand m-0 flex items-center gap-2 font-mono text-[0.68rem] font-semibold uppercase tracking-[0.12em]"; const GALLERY_BRAND_MARK_CLASS = "openpress-workspace-gallery__brand-mark text-[var(--op-workspace-text)]"; const GALLERY_BRAND_SEP_CLASS = "openpress-workspace-gallery__brand-sep tracking-normal text-[var(--op-workspace-text-muted)]"; const GALLERY_EYEBROW_CLASS = "openpress-workspace-gallery__eyebrow text-[var(--op-workspace-text-muted)]"; const GALLERY_VERSION_CLASS = "openpress-workspace-gallery__version rounded border border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-surface-muted)] px-1.5 py-0.5 text-[0.62rem] tracking-[0.08em] text-[var(--op-workspace-text-soft)]"; const GALLERY_TITLE_CLASS = "m-0 font-sans text-[clamp(1.4rem,2.6vw,2.2rem)] font-semibold leading-[1.1] tracking-[-0.02em] text-[var(--op-workspace-text)]"; const GALLERY_BODY_CLASS = "openpress-workspace-gallery__body grid grid-cols-[180px_1fr] items-start gap-10 max-[860px]:grid-cols-1"; const GALLERY_SIDEBAR_CLASS = "openpress-workspace-gallery__sidebar sticky top-6 flex flex-col gap-0.5 max-[860px]:static max-[860px]:flex-row max-[860px]:flex-wrap max-[860px]:gap-1.5"; const GALLERY_FILTER_CLASS = [ "openpress-workspace-gallery__filter-btn flex w-full cursor-pointer items-center justify-between gap-[0.6rem]", "rounded-[7px] border border-transparent bg-transparent px-3 py-[0.52rem] text-left font-sans text-[0.82rem]", "font-medium text-[var(--op-workspace-text-muted)] transition-[background,color,border-color] duration-[var(--op-workspace-duration-fast)]", "hover:bg-[var(--op-workspace-surface-hover)] hover:text-[var(--op-workspace-text)] max-[860px]:w-auto max-[860px]:shrink-0", ].join(" "); const GALLERY_FILTER_ACTIVE_CLASS = "!border-[var(--op-workspace-border-strong)] !bg-[var(--op-workspace-surface-hover)] !text-[var(--op-workspace-text)]"; const GALLERY_FILTER_LABEL_CLASS = "openpress-workspace-gallery__filter-label flex-auto"; const GALLERY_FILTER_COUNT_CLASS = "openpress-workspace-gallery__filter-count shrink-0 font-mono text-[0.72rem] font-medium tracking-[0.04em] text-[var(--op-workspace-text-muted)]"; const GALLERY_FILTER_COUNT_ACTIVE_CLASS = "!text-[var(--op-workspace-text)]"; const GALLERY_MAIN_CLASS = "openpress-workspace-gallery__main min-w-0"; const GALLERY_GRID_CLASS = "openpress-workspace-gallery__grid !m-0 grid !list-none grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] items-start gap-6 !p-0 max-[720px]:grid-cols-1"; const GALLERY_ITEM_CLASS = "openpress-workspace-gallery__item flex"; const GALLERY_EMPTY_CLASS = "openpress-workspace-gallery__empty m-0 py-12 text-[0.88rem] text-[var(--op-workspace-text-muted)]"; const GALLERY_NAV_DIVIDER_CLASS = "my-2 h-px w-full bg-[var(--op-workspace-border-muted)] max-[860px]:mx-1 max-[860px]:my-auto max-[860px]:h-6 max-[860px]:w-px"; const SETTINGS_CONTENT_CLASS = "openpress-workspace-settings-content min-w-0 grid content-start gap-9"; const SETTINGS_MAIN_CLASS = "openpress-workspace-settings mx-auto grid w-full max-w-[760px] content-start gap-9 py-1"; const SETTINGS_HEADER_CLASS = "grid gap-2 border-b border-[var(--op-workspace-border)] pb-5"; const SETTINGS_EYEBROW_CLASS = "m-0 font-mono text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-[var(--op-workspace-text-muted)]"; const SETTINGS_TITLE_CLASS = "m-0 text-[1.35rem] font-semibold tracking-[-0.02em] text-[var(--op-workspace-text)]"; const SETTINGS_DESCRIPTION_CLASS = "m-0 max-w-[580px] text-[0.82rem] leading-relaxed text-[var(--op-workspace-text-muted)]"; const SETTINGS_FORM_CLASS = "grid gap-8"; const SETTINGS_ROW_CLASS = "grid grid-cols-[minmax(140px,0.42fr)_minmax(0,1fr)] items-start gap-8 max-[720px]:grid-cols-1 max-[720px]:gap-3"; const SETTINGS_ROW_COPY_CLASS = "grid gap-1"; const SETTINGS_ROW_LABEL_CLASS = "m-0 text-[0.82rem] font-semibold text-[var(--op-workspace-text)]"; const SETTINGS_ROW_HELP_CLASS = "m-0 text-[0.72rem] leading-relaxed text-[var(--op-workspace-text-muted)]"; const SETTINGS_OPTIONS_CLASS = "flex min-w-0 flex-wrap gap-2"; const SETTINGS_OPTION_CLASS = [ "relative inline-flex min-h-9 cursor-pointer items-center gap-2 rounded-[var(--op-workspace-radius-md)]", "border border-[var(--op-workspace-border-muted)] bg-transparent px-3 text-[0.76rem] font-semibold text-[var(--op-workspace-text-muted)]", "hover:border-[var(--op-workspace-border-strong)] hover:text-[var(--op-workspace-text)]", ].join(" "); const SETTINGS_OPTION_ACTIVE_CLASS = "!border-[var(--op-workspace-accent-border)] !bg-[var(--op-workspace-accent-surface)] !text-[var(--op-workspace-accent)]"; const SETTINGS_CHECK_CLASS = "h-3.5 w-3.5"; const SETTINGS_SWATCH_CLASS = "h-3.5 w-3.5 shrink-0 rounded-full border border-black/10 shadow-[0_0_0_1px_rgb(255_255_255_/_0.12)]"; const SETTINGS_STATUS_CLASS = "m-0 text-[0.72rem] leading-relaxed text-[var(--op-workspace-text-muted)]"; const SETTINGS_ERROR_CLASS = "!text-[var(--op-workspace-danger)]"; const SETTINGS_SHORTCUT_GROUP_CLASS = "grid gap-3 border-t border-[var(--op-workspace-border-muted)] pt-5 first:border-t-0 first:pt-0"; const SETTINGS_SHORTCUT_GROUP_TITLE_CLASS = "m-0 text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[var(--op-workspace-text-muted)]"; const SETTINGS_SHORTCUT_LIST_CLASS = "m-0 grid list-none gap-2 p-0"; const SETTINGS_SHORTCUT_ROW_CLASS = "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 rounded-[var(--op-workspace-radius-md)] border border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-surface-muted)] px-3 py-2.5"; const SETTINGS_SHORTCUT_COMMAND_CLASS = "m-0 text-[0.76rem] font-semibold text-[var(--op-workspace-text-soft)]"; const SETTINGS_SHORTCUT_KEYS_CLASS = "flex flex-wrap justify-end gap-1.5"; const SETTINGS_SHORTCUT_CHORD_CLASS = "flex items-center gap-1"; const SETTINGS_SHORTCUT_KEYCAP_CLASS = "rounded-[4px] border border-[var(--op-workspace-border-strong)] bg-[var(--op-workspace-surface-raised)] px-1.5 py-0.5 font-mono text-[0.66rem] font-semibold text-[var(--op-workspace-text-soft)] shadow-[0_1px_0_var(--op-workspace-border-muted)]"; const HOTKEY_KEYCAP_SYMBOLS: Record = { ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", ArrowUp: "↑", Backspace: "⌫", Delete: "⌦", Enter: "↵", Escape: "Esc", PageDown: "⇟", PageUp: "⇞", Space: "␣", Tab: "⇥", }; const HOTKEY_KEYCAP_LABELS: Record = { ArrowDown: "Arrow Down", ArrowLeft: "Arrow Left", ArrowRight: "Arrow Right", ArrowUp: "Arrow Up", Backspace: "Backspace", Delete: "Delete", Enter: "Enter", Escape: "Escape", PageDown: "Page Down", PageUp: "Page Up", Space: "Space", Tab: "Tab", }; function isApplePlatform() { return typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform); } function getHotkeyKeycapLabel(key: string) { if (key === "primary") return isApplePlatform() ? "⌘" : "Ctrl"; return HOTKEY_KEYCAP_SYMBOLS[key] ?? (key.length === 1 ? key.toUpperCase() : key); } function getHotkeyKeycapAriaLabel(key: string) { if (key === "primary") return isApplePlatform() ? "Command" : "Control"; return HOTKEY_KEYCAP_LABELS[key] ?? (key.length === 1 ? key.toUpperCase() : key); } const GALLERY_CARD_CLASS = [ "openpress-workspace-gallery__card grid w-full cursor-pointer appearance-none grid-rows-[auto_3.6rem]", "self-start overflow-hidden rounded-[var(--op-workspace-radius-lg)] border border-[var(--op-workspace-card-border)] bg-[var(--op-workspace-card-surface)] p-0 text-left text-[var(--op-workspace-card-text)]", "transition-[transform,box-shadow,border-color] duration-[var(--op-workspace-duration-fast)] hover:-translate-y-0.5 hover:border-[var(--op-workspace-card-hover-border)] hover:[box-shadow:var(--op-workspace-card-hover-shadow)]", "focus-visible:-translate-y-0.5 focus-visible:border-[var(--op-workspace-card-hover-border)] focus-visible:[box-shadow:var(--op-workspace-card-hover-shadow)] focus-visible:outline-none", ].join(" "); const GALLERY_CARD_BODY_CLASS = [ "openpress-workspace-gallery__card-body grid min-h-0 grid-cols-[minmax(0,1fr)_1.9rem] items-center gap-3", "bg-[var(--op-workspace-card-surface)] px-[1.05rem] py-0", ].join(" "); const GALLERY_CARD_TITLE_CLASS = "openpress-workspace-gallery__title block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.92rem] font-bold leading-none text-[var(--op-workspace-card-text)]"; const GALLERY_TYPE_ICON_CLASS = [ "openpress-workspace-gallery__type-icon grid h-[1.9rem] w-[1.9rem] place-items-center rounded-[var(--op-workspace-radius-sm)]", "border border-[var(--op-workspace-card-border)] bg-[var(--op-workspace-surface-muted)] text-[var(--op-workspace-card-text-muted)]", "[&_svg]:h-[0.9rem] [&_svg]:w-[0.9rem]", ].join(" "); const GALLERY_THUMB_CLASS = [ "openpress-workspace-gallery__thumb relative block aspect-video w-full overflow-hidden border-b border-[var(--op-workspace-card-border)]", "bg-[var(--op-workspace-press-preview-bg)]", ].join(" "); const GALLERY_THUMB_STAGE_CLASS = "openpress-workspace-gallery__thumb-stage absolute inset-0 grid place-items-center"; const GALLERY_THUMB_FRAME_CLASS = "openpress-workspace-gallery__thumb-frame relative outline outline-1 outline-[var(--op-workspace-press-preview-outline)] [box-shadow:var(--op-workspace-press-preview-shadow)]"; const GALLERY_THUMB_PLACEHOLDER_CLASS = "openpress-workspace-gallery__thumb-placeholder absolute inset-0 grid place-items-center"; const GALLERY_THUMB_SKEL_CLASS = [ "openpress-workspace-gallery__thumb-skel block max-h-full max-w-full rounded-[3px] border border-[var(--op-workspace-press-preview-outline)] bg-[var(--op-workspace-card-surface)]", "[box-shadow:var(--op-workspace-press-preview-shadow)] [background:repeating-linear-gradient(135deg,var(--op-workspace-press-preview-grid)_0_6px,transparent_6px_14px),var(--op-workspace-card-surface)]", ].join(" "); const GALLERY_THUMB_IMAGE_CLASS = "openpress-workspace-gallery__thumb-image block h-full w-full object-contain"; const THUMB_PAGE_CLASS = "block select-none pointer-events-none"; export function WorkspaceGalleryPage({ manifest, view, onSelectPress, onOpenDocuments, onOpenSettings, }: Props) { const heading = manifest.name ?? "Workspace"; const [filter, setFilter] = useState("all"); const appearance = useWorkspaceAppearance(); const counts = { all: manifest.presses.length, pages: manifest.presses.filter((p) => p.type === "pages").length, slides: manifest.presses.filter((p) => p.type === "slides").length, }; const visiblePresses = filter === "all" ? manifest.presses : manifest.presses.filter((p) => p.type === filter); return (

open-press Workspace core v{corePackage.version}

{view === "settings" ? "Settings" : heading}

{view === "settings" ? ( ) : (
{visiblePresses.length > 0 ? (
    {visiblePresses.map((press) => (
  • onSelectPress(press)} />
  • ))}
) : (

No {filter} documents.

)}
)}
); } const MODE_LABELS: Record = { system: "System", dark: "Dark", light: "Light", }; const ACCENT_LABELS: Record = { amber: "Amber", blue: "Blue", emerald: "Emerald", violet: "Violet", rose: "Rose", }; const ACCENT_SWATCH_COLORS: Record = { amber: "#f0b64c", blue: "#60a5fa", emerald: "#34d399", violet: "#a78bfa", rose: "#fb7185", }; function WorkspaceAppearanceSettings({ colorModePreference, accent, writable, saving, error, updates, onColorModeChange, onAccentChange, }: { colorModePreference: WorkspaceColorModePreference; accent: WorkspaceAccent; writable: boolean; saving: boolean; error: string | null; updates: WorkspaceUpdatesInfo | null; onColorModeChange: (mode: WorkspaceColorModePreference) => void; onAccentChange: (accent: WorkspaceAccent) => void; }) { return (

Workspace preferences

Appearance

Customize the OpenPress workspace interface. Document themes and exported output stay unchanged.

{error ? `Could not save workspace settings: ${error}` : saving ? "Saving workspace settings…" : writable ? "Changes are saved to openpress/settings.json for this workspace." : "Published workspace settings are read-only."}

Mode

Match your system or choose a fixed interface mode.

{WORKSPACE_COLOR_MODE_OPTIONS.map((mode) => { const active = mode === colorModePreference; return ( ); })}

Accent

Applied only to Workspace controls and focus states.

{WORKSPACE_ACCENT_OPTIONS.map((option) => { const active = option === accent; return ( ); })}
); } const HOTKEY_CONTEXTS: Array<{ label: string; commands: (command: HotkeyCommand) => boolean }> = [ { label: "Workspace", commands: (command) => command.id.startsWith("workspace.") }, { label: "View", commands: (command) => command.id.startsWith("view.") }, { label: "Reader", commands: (command) => command.id.startsWith("reader.") }, { label: "Presentation", commands: (command) => command.id.startsWith("presentation.") }, { label: "Editing", commands: (command) => command.id.startsWith("editing.") }, { label: "Context controls", commands: (command) => !["workspace", "view", "reader", "presentation", "editing"].some((prefix) => command.id.startsWith(`${prefix}.`)) }, ]; function KeyboardShortcutsSettings() { return (

Keyboard shortcuts

Read-only reference for commands available in the Workspace.

{HOTKEY_CONTEXTS.map(({ label, commands }) => (

{label}

    {HOTKEY_COMMANDS.filter(commands).map((command) => (
  • {command.label}

    {command.shortcuts.map((shortcut, shortcutIndex) => ( {shortcutIndex > 0 ? : null} {shortcut.map((key) => ( {getHotkeyKeycapLabel(key)} ))} ))}
  • ))}
))}
); } function FilterButton({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelect: () => void }) { return ( ); } function PressTypeIcon({ type }: { type: WorkspaceManifestPress["type"] }) { const Icon = type === "slides" ? Presentation : FileText; return