1"use client";
2
3
8import { create } from "zustand";
9import { useEffect } from "react";
10
11export type ToastKind = "success" | "error" | "info";
12
13export interface Toast {
14 id: string;
15 kind: ToastKind;
16 title: string;
17 description?: string;
18
19 duration: number;
20 createdAt: number;
21}
22
23export type Theme = "light" | "dark" | "system";
24
25const THEME_KEY = "factory-theme";
26const SIDEBAR_KEY = "factory-sidebar-collapsed";
27const PRESENCE_TOASTS_KEY = "factory-presence-toasts";
28
29function readTheme(): Theme {
30 if (typeof localStorage === "undefined") return "system";
31 const v = localStorage.getItem(THEME_KEY);
32 return v === "light" || v === "dark" || v === "system" ? v : "system";
33}
34
35function readSidebar(): boolean {
36 if (typeof localStorage === "undefined") return false;
37 return localStorage.getItem(SIDEBAR_KEY) === "1";
38}
39
40
42function readPresenceToasts(): boolean {
43 if (typeof localStorage === "undefined") return true;
44 return localStorage.getItem(PRESENCE_TOASTS_KEY) !== "0";
45}
46
47
48export function applyTheme(theme: Theme): void {
49 if (typeof document === "undefined") return;
50 const dark =
51 theme === "dark" ||
52 (theme === "system" && window.matchMedia?.("(prefers-color-scheme: dark)").matches);
53 document.documentElement.classList.toggle("dark", Boolean(dark));
54}
55
56
57export type AssistantMode = "extract-actions" | "generate-faqs" | "find-duplicates" | "find-stale" | "suggest-metadata";
58
59
60export type BulkAction = "trash" | "archive" | "unarchive" | "favorite" | "unfavorite";
61
62interface UiState {
63 toasts: Toast[];
64 commandPaletteOpen: boolean;
65
66 searchOpen: boolean;
67
72 searchWorkspaceSlug: string;
73
81 aiPanelOpen: boolean;
82 aiPanelContext: { pageId?: string; blockId?: string; databaseId?: string; label?: string } | null;
83
89 generateOpen: boolean;
90 generateContext: { parentId?: string | null } | null;
91
97 assistantOpen: boolean;
98 assistantContext: { mode: AssistantMode; pageId?: string } | null;
99
105 moveOpen: boolean;
106 moveContext: { pageId: string; workspaceId: string; title?: string } | null;
107
113 pageSelection: { workspaceId: string; ids: string[] };
114 bulkOpen: boolean;
115
116 bulkAction: BulkAction | null;
117 sidebarCollapsed: boolean;
118
124 mobileNavOpen: boolean;
125 theme: Theme;
126
127 presenceToastsEnabled: boolean;
128 pushToast: (
129 toast: Omit<Toast, "id" | "createdAt" | "duration"> & { duration?: number },
130 ) => string;
131 removeToast: (id: string) => void;
132 setCommandPalette: (open: boolean) => void;
133 setSearchDialog: (open: boolean) => void;
134 setSearchWorkspace: (slug: string) => void;
135 setAiPanel: (open: boolean, context?: { pageId?: string; blockId?: string; databaseId?: string } | null) => void;
136 setGenerate: (open: boolean, context?: { parentId?: string | null } | null) => void;
137 setAssistant: (open: boolean, context?: { mode: AssistantMode; pageId?: string } | null) => void;
138 setMove: (open: boolean, context?: { pageId: string; workspaceId: string; title?: string } | null) => void;
139
140 setBulk: (open: boolean, action?: BulkAction | null) => void;
141
142 togglePageSelect: (workspaceId: string, id: string) => void;
143 clearPageSelection: () => void;
144 toggleSidebar: () => void;
145 setMobileNav: (open: boolean) => void;
146 setTheme: (theme: Theme) => void;
147 setPresenceToastsEnabled: (enabled: boolean) => void;
148}
149
150let toastSeq = 0;
151
152export const useUiStore = create<UiState>()((set, get) => ({
153 toasts: [],
154 commandPaletteOpen: false,
155 searchOpen: false,
156 searchWorkspaceSlug: "",
157 aiPanelOpen: false,
158 aiPanelContext: null,
159 generateOpen: false,
160 generateContext: null,
161 assistantOpen: false,
162 assistantContext: null,
163 moveOpen: false,
164 moveContext: null,
165 pageSelection: { workspaceId: "", ids: [] },
166 bulkOpen: false,
167 bulkAction: null,
168 sidebarCollapsed: readSidebar(),
169 mobileNavOpen: false,
170 theme: readTheme(),
171 presenceToastsEnabled: readPresenceToasts(),
172
173 pushToast: ({ title, description, kind, duration = 4000 }) => {
174 const id = `t${Date.now().toString(36)}-${(toastSeq += 1)}`;
175 const toast: Toast = { id, kind, title, description, duration, createdAt: Date.now() };
176 set({ toasts: [...get().toasts, toast].slice(-5) });
177 return id;
178 },
179
180 removeToast: (id) => set({ toasts: get().toasts.filter((t) => t.id !== id) }),
181
182 setCommandPalette: (commandPaletteOpen) => set({ commandPaletteOpen }),
183
184 setSearchDialog: (searchOpen) => set({ searchOpen }),
185
186 setSearchWorkspace: (searchWorkspaceSlug) => set({ searchWorkspaceSlug }),
187
188 setAiPanel: (aiPanelOpen, context) =>
189 set((s) => ({ aiPanelOpen, aiPanelContext: context !== undefined ? context : s.aiPanelContext })),
190
191 setGenerate: (generateOpen, context) =>
192 set((s) => ({ generateOpen, generateContext: context !== undefined ? context : s.generateContext })),
193
194 setAssistant: (assistantOpen, context) =>
195 set((s) => ({ assistantOpen, assistantContext: context !== undefined ? context : s.assistantContext })),
196
197 setMove: (moveOpen, context) =>
198 set((s) => ({ moveOpen, moveContext: context !== undefined ? context : s.moveContext })),
199
200 setBulk: (bulkOpen, action) =>
201 set((s) => ({ bulkOpen, bulkAction: bulkOpen && action ? action : null })),
202
203 togglePageSelect: (workspaceId, id) =>
204 set((s) => {
205 const cur = s.pageSelection;
206
207 const base = cur.workspaceId === workspaceId ? cur.ids : [];
208 const ids = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
209 return { pageSelection: { workspaceId: ids.length ? workspaceId : "", ids } };
210 }),
211
212 clearPageSelection: () =>
213 set({ pageSelection: { workspaceId: "", ids: [] }, bulkOpen: false, bulkAction: null }),
214
215 toggleSidebar: () => {
216 const sidebarCollapsed = !get().sidebarCollapsed;
217 try {
218 localStorage.setItem(SIDEBAR_KEY, sidebarCollapsed ? "1" : "0");
219 } catch {
220
221 }
222 set({ sidebarCollapsed });
223 },
224
225 setMobileNav: (mobileNavOpen) => set({ mobileNavOpen }),
226
227 setTheme: (theme) => {
228 try {
229 localStorage.setItem(THEME_KEY, theme);
230 } catch {
231
232 }
233 applyTheme(theme);
234 set({ theme });
235 },
236
237 setPresenceToastsEnabled: (presenceToastsEnabled) => {
238 try {
239 localStorage.setItem(PRESENCE_TOASTS_KEY, presenceToastsEnabled ? "1" : "0");
240 } catch {
241
242 }
243 set({ presenceToastsEnabled });
244 },
245}));
246
247
248export function toast(kind: ToastKind, title: string, description?: string): string {
249 return useUiStore.getState().pushToast({ kind, title, description });
250}
251
252
253export function useSystemThemeSync(): void {
254 const theme = useUiStore((s) => s.theme);
255 useEffect(() => {
256 if (theme !== "system" || typeof window === "undefined") return;
257 const mq = window.matchMedia("(prefers-color-scheme: dark)");
258 const handler = () => applyTheme("system");
259 mq.addEventListener("change", handler);
260 return () => mq.removeEventListener("change", handler);
261 }, [theme]);
262}
263