From 580e798e16346eb894eb899edac82d2efdf40adc Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 18 Apr 2026 22:32:53 -0500 Subject: initial commit --- src/hooks/usePackages.ts | 112 +++++++++++++++++++++++ src/hooks/useSettings.ts | 41 +++++++++ src/hooks/useWorkspace.ts | 225 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 src/hooks/usePackages.ts create mode 100644 src/hooks/useSettings.ts create mode 100644 src/hooks/useWorkspace.ts (limited to 'src/hooks') 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(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(load); + + const setSettings = (next: Partial) => { + 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 { + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const state = JSON.parse(raw) as Partial; + // 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 { + 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(saved.inputs ?? DEFAULT_INPUTS); + const [outputs, setOutputs] = useState(saved.outputs ?? null); + const [edits, setEditsRaw] = useState>(saved.edits ?? {}); + const [locked, setLocked] = useState(saved.locked ?? DEFAULT_LOCKED); + const [isGenerating, setIsGenerating] = useState(false); + const [generateMode, setGenerateMode] = useState<'template' | string>('template'); + const [generateError, setGenerateError] = useState(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + const editStack = useRef>>([saved.edits ?? {}]); + const stackIdx = useRef(0); + const abortRef = useRef(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, 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((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, + }; +} -- cgit v1.2.3