"use client"; /** * UI-only state (Zustand): toasts, command palette, sidebar, theme. * Per PRODUCT-SPEC §27: theme is light / dark / system, driven by CSS * variables (no hard-coded colors). */ import { create } from "zustand"; import { useEffect } from "react"; export type ToastKind = "success" | "error" | "info"; export interface Toast { id: string; kind: ToastKind; title: string; description?: string; /** Auto-dismiss delay in ms. */ duration: number; createdAt: number; } export type Theme = "light" | "dark" | "system"; const THEME_KEY = "factory-theme"; const SIDEBAR_KEY = "factory-sidebar-collapsed"; const PRESENCE_TOASTS_KEY = "factory-presence-toasts"; function readTheme(): Theme { if (typeof localStorage === "undefined") return "system"; const v = localStorage.getItem(THEME_KEY); return v === "light" || v === "dark" || v === "system" ? v : "system"; } function readSidebar(): boolean { if (typeof localStorage === "undefined") return false; return localStorage.getItem(SIDEBAR_KEY) === "1"; } /** Presence join/leave toasts — ON by default (spec R5); only an explicit * "0" disables them. */ function readPresenceToasts(): boolean { if (typeof localStorage === "undefined") return true; return localStorage.getItem(PRESENCE_TOASTS_KEY) !== "0"; } /** Apply the resolved theme class to . Safe to call from effects. */ export function applyTheme(theme: Theme): void { if (typeof document === "undefined") return; const dark = theme === "dark" || (theme === "system" && window.matchMedia?.("(prefers-color-scheme: dark)").matches); document.documentElement.classList.toggle("dark", Boolean(dark)); } /** T-172…T-175 (A-010) — the five knowledge-assistant capabilities. */ export type AssistantMode = "extract-actions" | "generate-faqs" | "find-duplicates" | "find-stale" | "suggest-metadata"; /** T-139 — the bulk page actions (matches the API's `POST /pages/bulk`). */ export type BulkAction = "trash" | "archive" | "unarchive" | "favorite" | "unfavorite"; interface UiState { toasts: Toast[]; commandPaletteOpen: boolean; /** Global search dialog (⌘K, TASK-0098). */ searchOpen: boolean; /** * The workspace SLUG the search dialog scopes to (the (app) shell keeps it * in sync with the [workspace] route param so the dialog — mounted at the * group layout, outside the route — knows which workspace to search). */ searchWorkspaceSlug: string; /** * TASK-0122 — the Ask AI panel (mounted at the workspace shell, like the * search dialog, so every view's entry points can open it). `context` is * the resource binding set by the entry point (page header → pageId, a * block menu → pageId+blockId, a database header → databaseId); the panel * seeds a conversation with it on first open and sends it with the first * message (the server assembles the content — never the client). */ aiPanelOpen: boolean; aiPanelContext: { pageId?: string; blockId?: string; databaseId?: string; label?: string } | null; /** * TASK-0126 — the "Generate page with AI" dialog (mounted at the workspace * shell, like the Ask AI panel, so the command palette / page context menu / * Home empty-state card can all open it). `context` carries the optional * `parentId` (the page to nest the generated page under). */ generateOpen: boolean; generateContext: { parentId?: string | null } | null; /** * T-172…T-175 (A-010) — the AI knowledge-assistant dialog (mounted at the * workspace shell, like the generate dialog, so the command palette can * open any of the five capabilities). `mode` selects the capability; * `pageId` scopes the page-bound ones (generate-faqs / suggest-metadata). */ assistantOpen: boolean; assistantContext: { mode: AssistantMode; pageId?: string } | null; /** * "Move page to…" dialog (mounted at the workspace shell, like the search * and generate dialogs, so entry points from the topbar and the sidebar * tree can all open it). `context` names the page being moved plus its * workspace (for the query keys). */ moveOpen: boolean; moveContext: { pageId: string; workspaceId: string; title?: string } | null; /** * T-139: sidebar bulk multi-select. The selection is scoped to one workspace * (the sidebar tree is the only selector) and cleared on close / successful * bulk action. `bulkOpen` gates the confirm dialog (mounted at the workspace * shell, like MoveToDialog). */ pageSelection: { workspaceId: string; ids: string[] }; bulkOpen: boolean; /** T-139: the action the confirm dialog is pending for (null = closed). */ bulkAction: BulkAction | null; sidebarCollapsed: boolean; /** * Mobile (< 640 px) sidebar drawer (docs/product/ux-requirements.md — the * sidebar is a drawer at the mobile breakpoint, z-90, closed by default so * the editor is full-width single-column). Distinct from `sidebarCollapsed` * (the desktop in-flow collapse). */ mobileNavOpen: boolean; theme: Theme; /** Presence join/leave toasts (TASK-0093 R5) — default ON. */ presenceToastsEnabled: boolean; pushToast: ( toast: Omit & { duration?: number }, ) => string; removeToast: (id: string) => void; setCommandPalette: (open: boolean) => void; setSearchDialog: (open: boolean) => void; setSearchWorkspace: (slug: string) => void; setAiPanel: (open: boolean, context?: { pageId?: string; blockId?: string; databaseId?: string } | null) => void; setGenerate: (open: boolean, context?: { parentId?: string | null } | null) => void; setAssistant: (open: boolean, context?: { mode: AssistantMode; pageId?: string } | null) => void; setMove: (open: boolean, context?: { pageId: string; workspaceId: string; title?: string } | null) => void; /** T-139: open/close the bulk confirm dialog, naming the pending action. */ setBulk: (open: boolean, action?: BulkAction | null) => void; /** T-139: toggle a page id in the bulk selection (scoped to its workspace). */ togglePageSelect: (workspaceId: string, id: string) => void; clearPageSelection: () => void; toggleSidebar: () => void; setMobileNav: (open: boolean) => void; setTheme: (theme: Theme) => void; setPresenceToastsEnabled: (enabled: boolean) => void; } let toastSeq = 0; export const useUiStore = create()((set, get) => ({ toasts: [], commandPaletteOpen: false, searchOpen: false, searchWorkspaceSlug: "", aiPanelOpen: false, aiPanelContext: null, generateOpen: false, generateContext: null, assistantOpen: false, assistantContext: null, moveOpen: false, moveContext: null, pageSelection: { workspaceId: "", ids: [] }, bulkOpen: false, bulkAction: null, sidebarCollapsed: readSidebar(), mobileNavOpen: false, theme: readTheme(), presenceToastsEnabled: readPresenceToasts(), pushToast: ({ title, description, kind, duration = 4000 }) => { const id = `t${Date.now().toString(36)}-${(toastSeq += 1)}`; const toast: Toast = { id, kind, title, description, duration, createdAt: Date.now() }; set({ toasts: [...get().toasts, toast].slice(-5) }); return id; }, removeToast: (id) => set({ toasts: get().toasts.filter((t) => t.id !== id) }), setCommandPalette: (commandPaletteOpen) => set({ commandPaletteOpen }), setSearchDialog: (searchOpen) => set({ searchOpen }), setSearchWorkspace: (searchWorkspaceSlug) => set({ searchWorkspaceSlug }), setAiPanel: (aiPanelOpen, context) => set((s) => ({ aiPanelOpen, aiPanelContext: context !== undefined ? context : s.aiPanelContext })), setGenerate: (generateOpen, context) => set((s) => ({ generateOpen, generateContext: context !== undefined ? context : s.generateContext })), setAssistant: (assistantOpen, context) => set((s) => ({ assistantOpen, assistantContext: context !== undefined ? context : s.assistantContext })), setMove: (moveOpen, context) => set((s) => ({ moveOpen, moveContext: context !== undefined ? context : s.moveContext })), setBulk: (bulkOpen, action) => set((s) => ({ bulkOpen, bulkAction: bulkOpen && action ? action : null })), togglePageSelect: (workspaceId, id) => set((s) => { const cur = s.pageSelection; // A selection belongs to ONE workspace; switching workspaces resets it. const base = cur.workspaceId === workspaceId ? cur.ids : []; const ids = base.includes(id) ? base.filter((x) => x !== id) : [...base, id]; return { pageSelection: { workspaceId: ids.length ? workspaceId : "", ids } }; }), clearPageSelection: () => set({ pageSelection: { workspaceId: "", ids: [] }, bulkOpen: false, bulkAction: null }), toggleSidebar: () => { const sidebarCollapsed = !get().sidebarCollapsed; try { localStorage.setItem(SIDEBAR_KEY, sidebarCollapsed ? "1" : "0"); } catch { /* storage unavailable — non-fatal */ } set({ sidebarCollapsed }); }, setMobileNav: (mobileNavOpen) => set({ mobileNavOpen }), setTheme: (theme) => { try { localStorage.setItem(THEME_KEY, theme); } catch { /* storage unavailable — non-fatal */ } applyTheme(theme); set({ theme }); }, setPresenceToastsEnabled: (presenceToastsEnabled) => { try { localStorage.setItem(PRESENCE_TOASTS_KEY, presenceToastsEnabled ? "1" : "0"); } catch { /* storage unavailable — non-fatal */ } set({ presenceToastsEnabled }); }, })); /** Imperative toast helper usable outside components (e.g. api error paths). */ export function toast(kind: ToastKind, title: string, description?: string): string { return useUiStore.getState().pushToast({ kind, title, description }); } /** Keep the system theme in sync while `theme === "system"`. */ export function useSystemThemeSync(): void { const theme = useUiStore((s) => s.theme); useEffect(() => { if (theme !== "system" || typeof window === "undefined") return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); const handler = () => applyTheme("system"); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, [theme]); }