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}