summaryrefslogtreecommitdiff
path: root/src/hooks
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
committerChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
commit580e798e16346eb894eb899edac82d2efdf40adc (patch)
tree6dec3b7db678f781138b40d34da37d21ac8fbe7d /src/hooks
downloadbrand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.gz
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.bz2
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.zip
initial commit
Diffstat (limited to 'src/hooks')
-rw-r--r--src/hooks/usePackages.ts112
-rw-r--r--src/hooks/useSettings.ts41
-rw-r--r--src/hooks/useWorkspace.ts225
3 files changed, 378 insertions, 0 deletions
diff --git a/src/hooks/usePackages.ts b/src/hooks/usePackages.ts
new file mode 100644
index 0000000..b137160
--- /dev/null
+++ b/src/hooks/usePackages.ts
@@ -0,0 +1,112 @@
+import { useState, useCallback } from 'react';
+
+export interface PackageSlot {
+ id: string;
+ name: string;
+ createdAt: string;
+ storageKey: string;
+}
+
+interface PackagesStore {
+ activeId: string;
+ slots: PackageSlot[];
+}
+
+const STORE_KEY = 'bw:packages';
+const LEGACY_KEY = 'bw:workspace';
+
+function makeId(): string {
+ return `pkg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+function makeSlot(name: string, id = makeId()): PackageSlot {
+ return { id, name, createdAt: new Date().toISOString(), storageKey: `bw:ws:${id}` };
+}
+
+function loadStore(): PackagesStore {
+ try {
+ const raw = localStorage.getItem(STORE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch { /* ignore */ }
+
+ // First run — migrate any legacy single-workspace data into the first slot
+ const firstSlot = makeSlot('Package 1');
+ try {
+ const legacy = localStorage.getItem(LEGACY_KEY);
+ if (legacy) localStorage.setItem(firstSlot.storageKey, legacy);
+ } catch { /* ignore */ }
+
+ const store: PackagesStore = { activeId: firstSlot.id, slots: [firstSlot] };
+ saveStore(store);
+ return store;
+}
+
+function saveStore(store: PackagesStore): void {
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch { /* ignore */ }
+}
+
+export function usePackages() {
+ const [store, setStore] = useState<PackagesStore>(loadStore);
+
+ const activeSlot = store.slots.find(s => s.id === store.activeId) ?? store.slots[0];
+
+ // useWorkspace saves directly to each slot's storageKey on every change,
+ // so switching just needs to update activeId — no snapshot/copy required.
+ const switchTo = useCallback((id: string) => {
+ setStore(prev => {
+ if (prev.activeId === id) return prev;
+ const next = { ...prev, activeId: id };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const createNew = useCallback(() => {
+ setStore(prev => {
+ const slot = makeSlot(`Package ${prev.slots.length + 1}`);
+ // New slot has no data in localStorage — useWorkspace will start fresh
+ const next = { activeId: slot.id, slots: [...prev.slots, slot] };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ setStore(prev => {
+ if (prev.slots.length <= 1) return prev;
+ const target = prev.slots.find(s => s.id === id);
+ try { if (target) localStorage.removeItem(target.storageKey); } catch { /* ignore */ }
+ const slots = prev.slots.filter(s => s.id !== id);
+ const activeId = prev.activeId === id ? slots[0].id : prev.activeId;
+ const next = { activeId, slots };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const rename = useCallback((id: string, name: string) => {
+ setStore(prev => {
+ const slots = prev.slots.map(s => s.id === id ? { ...s, name } : s);
+ const next = { ...prev, slots };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const duplicate = useCallback((id: string) => {
+ setStore(prev => {
+ const source = prev.slots.find(s => s.id === id);
+ if (!source) return prev;
+ const newSlot = makeSlot(`${source.name} copy`);
+ try {
+ const data = localStorage.getItem(source.storageKey);
+ if (data) localStorage.setItem(newSlot.storageKey, data);
+ } catch { /* ignore */ }
+ const next = { activeId: newSlot.id, slots: [...prev.slots, newSlot] };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ return { slots: store.slots, activeId: store.activeId, activeSlot, switchTo, createNew, remove, rename, duplicate };
+}
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
new file mode 100644
index 0000000..2452945
--- /dev/null
+++ b/src/hooks/useSettings.ts
@@ -0,0 +1,41 @@
+import { useState } from 'react';
+
+export interface OllamaSettings {
+ enabled: boolean;
+ baseUrl: string;
+ model: string;
+}
+
+const DEFAULTS: OllamaSettings = {
+ enabled: false,
+ baseUrl: 'http://localhost:11434',
+ model: 'llama3.2',
+};
+
+const KEY = 'bw:settings';
+
+function load(): OllamaSettings {
+ try {
+ const raw = localStorage.getItem(KEY);
+ return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS };
+ } catch {
+ return { ...DEFAULTS };
+ }
+}
+
+// Non-reactive read — used inside callbacks without needing the hook
+export function readSettings(): OllamaSettings {
+ return load();
+}
+
+export function useSettings() {
+ const [settings, setSettingsRaw] = useState<OllamaSettings>(load);
+
+ const setSettings = (next: Partial<OllamaSettings>) => {
+ const merged = { ...settings, ...next };
+ try { localStorage.setItem(KEY, JSON.stringify(merged)); } catch { /* ignore */ }
+ setSettingsRaw(merged);
+ };
+
+ return { settings, setSettings };
+}
diff --git a/src/hooks/useWorkspace.ts b/src/hooks/useWorkspace.ts
new file mode 100644
index 0000000..c583269
--- /dev/null
+++ b/src/hooks/useWorkspace.ts
@@ -0,0 +1,225 @@
+import { useState, useCallback, useRef } from 'react';
+import type { BrandInputs, BrandOutputs, LockedSections, WorkspaceState } from '../types';
+import { generate } from '../engine/generator';
+import { generateWithAI } from '../engine/aiGenerator';
+import { readSettings } from './useSettings';
+import { sanitizeOutputs } from '../lib/sanitize';
+
+const DEFAULT_INPUTS: BrandInputs = {
+ name: '',
+ category: '',
+ purpose: '',
+ audience: '',
+ tone: [],
+ avoid: [],
+ notes: '',
+};
+
+const DEFAULT_LOCKED: LockedSections = {
+ overview: false,
+ positioning: false,
+ tone: false,
+ messaging: false,
+ visual: false,
+ palette: false,
+ typography: false,
+ logo: false,
+ usage: false,
+ constraints: false,
+};
+
+function loadState(storageKey: string): Partial<WorkspaceState> {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (raw) {
+ const state = JSON.parse(raw) as Partial<WorkspaceState>;
+ // Always run through sanitizeOutputs — coerces any corrupted/legacy field
+ // (e.g. AI-returned objects in string fields) so React never crashes on render.
+ if (state.outputs) {
+ state.outputs = sanitizeOutputs(state.outputs) ?? null;
+ if (!state.outputs) state.edits = {};
+ }
+ return state;
+ }
+ } catch { /* ignore */ }
+ return {};
+}
+
+function saveState(storageKey: string, state: WorkspaceState): void {
+ try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch { /* ignore */ }
+}
+
+function applyLocks(
+ merged: BrandOutputs,
+ locked: LockedSections,
+ outputs: BrandOutputs | null,
+ edits: Partial<BrandOutputs>,
+): BrandOutputs {
+ if (!outputs) return merged;
+ if (locked.overview) merged.overview = edits.overview ?? outputs.overview;
+ if (locked.positioning) merged.positioning = edits.positioning ?? outputs.positioning;
+ if (locked.tone) merged.tone = edits.tone ?? outputs.tone;
+ if (locked.messaging) {
+ merged.titles = edits.titles ?? outputs.titles;
+ merged.subtitles = edits.subtitles ?? outputs.subtitles;
+ merged.taglines = edits.taglines ?? outputs.taglines;
+ }
+ if (locked.visual) merged.visualDirections = edits.visualDirections ?? outputs.visualDirections;
+ if (locked.palette) merged.palette = edits.palette ?? outputs.palette;
+ if (locked.typography) merged.typography = edits.typography ?? outputs.typography;
+ if (locked.logo) merged.logoConcepts = edits.logoConcepts ?? outputs.logoConcepts;
+ if (locked.usage) merged.usageExamples = edits.usageExamples ?? outputs.usageExamples;
+ if (locked.constraints) merged.constraints = edits.constraints ?? outputs.constraints;
+ return merged;
+}
+
+export function useWorkspace(storageKey = 'bw:workspace') {
+ const saved = loadState(storageKey);
+
+ const [inputs, setInputsRaw] = useState<BrandInputs>(saved.inputs ?? DEFAULT_INPUTS);
+ const [outputs, setOutputs] = useState<BrandOutputs | null>(saved.outputs ?? null);
+ const [edits, setEditsRaw] = useState<Partial<BrandOutputs>>(saved.edits ?? {});
+ const [locked, setLocked] = useState<LockedSections>(saved.locked ?? DEFAULT_LOCKED);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [generateMode, setGenerateMode] = useState<'template' | string>('template');
+ const [generateError, setGenerateError] = useState<string | null>(null);
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ const editStack = useRef<Array<Partial<BrandOutputs>>>([saved.edits ?? {}]);
+ const stackIdx = useRef(0);
+ const abortRef = useRef<AbortController | null>(null);
+
+ const stateRef = useRef({ inputs, outputs, edits, locked });
+ stateRef.current = { inputs, outputs, edits, locked };
+
+ const syncUndoRedo = () => {
+ setCanUndo(stackIdx.current > 0);
+ setCanRedo(stackIdx.current < editStack.current.length - 1);
+ };
+
+ const setEdits = useCallback((next: Partial<BrandOutputs>, pushHistory: boolean) => {
+ if (pushHistory) {
+ editStack.current = editStack.current.slice(0, stackIdx.current + 1);
+ editStack.current.push(next);
+ stackIdx.current = editStack.current.length - 1;
+ }
+ setEditsRaw(next);
+ syncUndoRedo();
+ }, []);
+
+ const persistInputs = useCallback((value: BrandInputs) => {
+ const { outputs, edits, locked } = stateRef.current;
+ saveState(storageKey, { inputs: value, outputs, edits, locked });
+ }, [storageKey]);
+
+ const setInputs = useCallback((next: BrandInputs | ((prev: BrandInputs) => BrandInputs)) => {
+ setInputsRaw(prev => {
+ const value = typeof next === 'function' ? next(prev) : next;
+ persistInputs(value);
+ return value;
+ });
+ }, [persistInputs]);
+
+ const runGenerate = useCallback(async () => {
+ // Cancel any in-flight request
+ abortRef.current?.abort();
+ const abort = new AbortController();
+ abortRef.current = abort;
+
+ const { inputs, outputs, edits, locked } = stateRef.current;
+ const settings = readSettings();
+
+ setIsGenerating(true);
+ setGenerateError(null);
+ setGenerateMode(settings.enabled ? settings.model : 'template');
+
+ try {
+ let newOutputs: BrandOutputs;
+
+ if (settings.enabled) {
+ newOutputs = await generateWithAI(inputs, settings, abort.signal);
+ } else {
+ await new Promise(r => setTimeout(r, 120));
+ if (abort.signal.aborted) return;
+ newOutputs = generate(inputs);
+ }
+
+ if (abort.signal.aborted) return;
+
+ const merged = applyLocks({ ...newOutputs }, locked, outputs, edits);
+ setOutputs(merged);
+ editStack.current = [{}];
+ stackIdx.current = 0;
+ setEditsRaw({});
+ syncUndoRedo();
+ saveState(storageKey, { inputs, outputs: merged, edits: {}, locked });
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') return;
+ setGenerateError((err as Error).message);
+ } finally {
+ setIsGenerating(false);
+ }
+ }, [storageKey]);
+
+ const cancelGenerate = useCallback(() => {
+ abortRef.current?.abort();
+ setIsGenerating(false);
+ setGenerateError(null);
+ }, []);
+
+ const updateEdit = useCallback(<K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => {
+ const { inputs, outputs, locked } = stateRef.current;
+ const next = { ...stateRef.current.edits, [key]: value };
+ setEdits(next, true);
+ saveState(storageKey, { inputs, outputs, edits: next, locked });
+ }, [storageKey, setEdits]);
+
+ const undo = useCallback(() => {
+ if (stackIdx.current <= 0) return;
+ stackIdx.current -= 1;
+ const prev = editStack.current[stackIdx.current];
+ const { inputs, outputs, locked } = stateRef.current;
+ setEdits(prev, false);
+ saveState(storageKey, { inputs, outputs, edits: prev, locked });
+ }, [storageKey, setEdits]);
+
+ const redo = useCallback(() => {
+ if (stackIdx.current >= editStack.current.length - 1) return;
+ stackIdx.current += 1;
+ const next = editStack.current[stackIdx.current];
+ const { inputs, outputs, locked } = stateRef.current;
+ setEdits(next, false);
+ saveState(storageKey, { inputs, outputs, edits: next, locked });
+ }, [storageKey, setEdits]);
+
+ const toggleLock = useCallback((section: keyof LockedSections) => {
+ setLocked(prev => {
+ const next = { ...prev, [section]: !prev[section] };
+ const { inputs, outputs, edits } = stateRef.current;
+ saveState(storageKey, { inputs, outputs, edits, locked: next });
+ return next;
+ });
+ }, [storageKey]);
+
+ const effectiveOutputs: BrandOutputs | null = outputs ? { ...outputs, ...edits } : null;
+
+ return {
+ inputs,
+ setInputs,
+ outputs: effectiveOutputs,
+ isGenerating,
+ generateMode,
+ generateError,
+ locked,
+ runGenerate,
+ cancelGenerate,
+ updateEdit,
+ toggleLock,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ hasOutput: outputs !== null,
+ };
+}