krz/brand-bench
A dynamic brand documentation generator with Ollama integration.
clone: git clone https://gitbay.org/krz/brand-bench.git
main: src/hooks/useWorkspace.ts · raw
1import { useState, useCallback, useRef } from 'react';
2import type { BrandInputs, BrandOutputs, LockedSections, WorkspaceState } from '../types';
3import { generate } from '../engine/generator';
4import { generateWithAI } from '../engine/aiGenerator';
5import { readSettings } from './useSettings';
6import { sanitizeOutputs } from '../lib/sanitize';
7
8const DEFAULT_INPUTS: BrandInputs = {
9 name: '',
10 category: '',
11 purpose: '',
12 audience: '',
13 tone: [],
14 avoid: [],
15 notes: '',
16};
17
18const DEFAULT_LOCKED: LockedSections = {
19 overview: false,
20 positioning: false,
21 tone: false,
22 messaging: false,
23 visual: false,
24 palette: false,
25 typography: false,
26 logo: false,
27 usage: false,
28 constraints: false,
29};
30
31function loadState(storageKey: string): Partial<WorkspaceState> {
32 try {
33 const raw = localStorage.getItem(storageKey);
34 if (raw) {
35 const state = JSON.parse(raw) as Partial<WorkspaceState>;
36 // Always run through sanitizeOutputs — coerces any corrupted/legacy field
37 // (e.g. AI-returned objects in string fields) so React never crashes on render.
38 if (state.outputs) {
39 state.outputs = sanitizeOutputs(state.outputs) ?? null;
40 if (!state.outputs) state.edits = {};
41 }
42 return state;
43 }
44 } catch { /* ignore */ }
45 return {};
46}
47
48function saveState(storageKey: string, state: WorkspaceState): void {
49 try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch { /* ignore */ }
50}
51
52function applyLocks(
53 merged: BrandOutputs,
54 locked: LockedSections,
55 outputs: BrandOutputs | null,
56 edits: Partial<BrandOutputs>,
57): BrandOutputs {
58 if (!outputs) return merged;
59 if (locked.overview) merged.overview = edits.overview ?? outputs.overview;
60 if (locked.positioning) merged.positioning = edits.positioning ?? outputs.positioning;
61 if (locked.tone) merged.tone = edits.tone ?? outputs.tone;
62 if (locked.messaging) {
63 merged.titles = edits.titles ?? outputs.titles;
64 merged.subtitles = edits.subtitles ?? outputs.subtitles;
65 merged.taglines = edits.taglines ?? outputs.taglines;
66 }
67 if (locked.visual) merged.visualDirections = edits.visualDirections ?? outputs.visualDirections;
68 if (locked.palette) merged.palette = edits.palette ?? outputs.palette;
69 if (locked.typography) merged.typography = edits.typography ?? outputs.typography;
70 if (locked.logo) merged.logoConcepts = edits.logoConcepts ?? outputs.logoConcepts;
71 if (locked.usage) merged.usageExamples = edits.usageExamples ?? outputs.usageExamples;
72 if (locked.constraints) merged.constraints = edits.constraints ?? outputs.constraints;
73 return merged;
74}
75
76export function useWorkspace(storageKey = 'bw:workspace') {
77 const saved = loadState(storageKey);
78
79 const [inputs, setInputsRaw] = useState<BrandInputs>(saved.inputs ?? DEFAULT_INPUTS);
80 const [outputs, setOutputs] = useState<BrandOutputs | null>(saved.outputs ?? null);
81 const [edits, setEditsRaw] = useState<Partial<BrandOutputs>>(saved.edits ?? {});
82 const [locked, setLocked] = useState<LockedSections>(saved.locked ?? DEFAULT_LOCKED);
83 const [isGenerating, setIsGenerating] = useState(false);
84 const [generateMode, setGenerateMode] = useState<'template' | string>('template');
85 const [generateError, setGenerateError] = useState<string | null>(null);
86 const [canUndo, setCanUndo] = useState(false);
87 const [canRedo, setCanRedo] = useState(false);
88
89 const editStack = useRef<Array<Partial<BrandOutputs>>>([saved.edits ?? {}]);
90 const stackIdx = useRef(0);
91 const abortRef = useRef<AbortController | null>(null);
92
93 const stateRef = useRef({ inputs, outputs, edits, locked });
94 stateRef.current = { inputs, outputs, edits, locked };
95
96 const syncUndoRedo = () => {
97 setCanUndo(stackIdx.current > 0);
98 setCanRedo(stackIdx.current < editStack.current.length - 1);
99 };
100
101 const setEdits = useCallback((next: Partial<BrandOutputs>, pushHistory: boolean) => {
102 if (pushHistory) {
103 editStack.current = editStack.current.slice(0, stackIdx.current + 1);
104 editStack.current.push(next);
105 stackIdx.current = editStack.current.length - 1;
106 }
107 setEditsRaw(next);
108 syncUndoRedo();
109 }, []);
110
111 const persistInputs = useCallback((value: BrandInputs) => {
112 const { outputs, edits, locked } = stateRef.current;
113 saveState(storageKey, { inputs: value, outputs, edits, locked });
114 }, [storageKey]);
115
116 const setInputs = useCallback((next: BrandInputs | ((prev: BrandInputs) => BrandInputs)) => {
117 setInputsRaw(prev => {
118 const value = typeof next === 'function' ? next(prev) : next;
119 persistInputs(value);
120 return value;
121 });
122 }, [persistInputs]);
123
124 const runGenerate = useCallback(async () => {
125 // Cancel any in-flight request
126 abortRef.current?.abort();
127 const abort = new AbortController();
128 abortRef.current = abort;
129
130 const { inputs, outputs, edits, locked } = stateRef.current;
131 const settings = readSettings();
132
133 setIsGenerating(true);
134 setGenerateError(null);
135 setGenerateMode(settings.enabled ? settings.model : 'template');
136
137 try {
138 let newOutputs: BrandOutputs;
139
140 if (settings.enabled) {
141 newOutputs = await generateWithAI(inputs, settings, abort.signal);
142 } else {
143 await new Promise(r => setTimeout(r, 120));
144 if (abort.signal.aborted) return;
145 newOutputs = generate(inputs);
146 }
147
148 if (abort.signal.aborted) return;
149
150 const merged = applyLocks({ ...newOutputs }, locked, outputs, edits);
151 setOutputs(merged);
152 editStack.current = [{}];
153 stackIdx.current = 0;
154 setEditsRaw({});
155 syncUndoRedo();
156 saveState(storageKey, { inputs, outputs: merged, edits: {}, locked });
157 } catch (err) {
158 if ((err as Error).name === 'AbortError') return;
159 setGenerateError((err as Error).message);
160 } finally {
161 setIsGenerating(false);
162 }
163 }, [storageKey]);
164
165 const cancelGenerate = useCallback(() => {
166 abortRef.current?.abort();
167 setIsGenerating(false);
168 setGenerateError(null);
169 }, []);
170
171 const updateEdit = useCallback(<K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => {
172 const { inputs, outputs, locked } = stateRef.current;
173 const next = { ...stateRef.current.edits, [key]: value };
174 setEdits(next, true);
175 saveState(storageKey, { inputs, outputs, edits: next, locked });
176 }, [storageKey, setEdits]);
177
178 const undo = useCallback(() => {
179 if (stackIdx.current <= 0) return;
180 stackIdx.current -= 1;
181 const prev = editStack.current[stackIdx.current];
182 const { inputs, outputs, locked } = stateRef.current;
183 setEdits(prev, false);
184 saveState(storageKey, { inputs, outputs, edits: prev, locked });
185 }, [storageKey, setEdits]);
186
187 const redo = useCallback(() => {
188 if (stackIdx.current >= editStack.current.length - 1) return;
189 stackIdx.current += 1;
190 const next = editStack.current[stackIdx.current];
191 const { inputs, outputs, locked } = stateRef.current;
192 setEdits(next, false);
193 saveState(storageKey, { inputs, outputs, edits: next, locked });
194 }, [storageKey, setEdits]);
195
196 const toggleLock = useCallback((section: keyof LockedSections) => {
197 setLocked(prev => {
198 const next = { ...prev, [section]: !prev[section] };
199 const { inputs, outputs, edits } = stateRef.current;
200 saveState(storageKey, { inputs, outputs, edits, locked: next });
201 return next;
202 });
203 }, [storageKey]);
204
205 const effectiveOutputs: BrandOutputs | null = outputs ? { ...outputs, ...edits } : null;
206
207 return {
208 inputs,
209 setInputs,
210 outputs: effectiveOutputs,
211 isGenerating,
212 generateMode,
213 generateError,
214 locked,
215 runGenerate,
216 cancelGenerate,
217 updateEdit,
218 toggleLock,
219 undo,
220 redo,
221 canUndo,
222 canRedo,
223 hasOutput: outputs !== null,
224 };
225}