import { v4 as uuid } from 'uuid' import { create } from 'zustand' import { validateConnection } from '../domain/compatibility' import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram' import type { CableBundle, Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types' import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository' import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository' // This is exactly the swap the repository pattern was built for: only this // one line changed to move off localStorage-only and onto real (locally // self-hosted, for now) Supabase persistence — nothing else in the store or // UI layers needed to change. LocalStorageDiagramRepository is still in the // codebase as a reference implementation / fallback, just no longer active. const repository: DiagramRepository = new SupabaseDiagramRepository() // Which diagram to reopen on next launch — a session/UI preference, not app // data, so it lives in localStorage even though the diagrams themselves are // in Supabase now. Falls back gracefully (see loadInitialDiagram) if the // referenced diagram no longer exists. const LAST_DIAGRAM_ID_KEY = 'av-planner:lastDiagramId' function getLastDiagramId(): string | null { try { return window.localStorage.getItem(LAST_DIAGRAM_ID_KEY) } catch { return null } } function setLastDiagramId(id: string): void { try { window.localStorage.setItem(LAST_DIAGRAM_ID_KEY, id) } catch { // Not essential — worst case, next launch falls back to the most // recently updated diagram instead of this exact one. } } async function loadMostRecent(): Promise { const list = await repository.list() if (list.length === 0) return null return repository.loadById(list[0].id) } interface DiagramStoreState { diagram: Diagram isLoaded: boolean /** Every diagram the current user can see, most recently updated first. * Populated by refreshDiagramList — not kept live-synced on every store * change, just refreshed at the moments that matter (initial load, and * whenever the diagram manager UI opens or changes something). */ diagramList: DiagramSummary[] /** Who owns the *currently open* diagram and what the current user can do * with it — refreshed whenever the open diagram changes, not kept live * (matches §8's turn-based, not realtime, collaboration model: a * permission change made by the owner while you're viewing takes effect * next time you load the diagram, not mid-session). Null only during the * brief window before the very first diagram finishes loading. */ access: DiagramAccess | null /** Set to a snapshot's id right after restoring it, meaning the live * diagram data is verbatim identical to that already-existing snapshot * row — see applyRestoredDiagram. `restoredDiagramUpdatedAt` is the * diagram's own `updatedAt` at that moment; touch() always advances * `updatedAt` on a genuine edit, so comparing the two later is a free * way to tell "has anything changed since this restore" without * hooking every mutating action. Both null once anything invalidates * the match (an edit, or loading/switching to a diagram fresh). Used by * DiagramSnapshotStore to skip writing a pointless duplicate safety * snapshot when the user restores several old versions in a row without * editing in between. */ restoredFromSnapshotId: string | null restoredDiagramUpdatedAt: string | null selectedDeviceId: string | null selectedConnectionId: string | null lastConnectionError: string | null clearConnectionError: () => void /** Figures out which diagram to open on launch (last one you had open, else * most recently updated, else a fresh empty one) and loads it. */ loadInitialDiagram: () => Promise refreshDiagramList: () => Promise /** Creates a new diagram, makes it current, and persists it immediately * (not just via the debounced autosave) so it shows up in the list right away. */ newDiagram: (name?: string) => Promise renameDiagram: (name: string) => void /** Imports diagram data under a fresh id, so it's added as a new diagram * rather than risking a collision with (or silently overwriting) an * existing one that happens to share the file's id. */ importDiagram: (diagram: Diagram) => Promise /** Applies a diagram that was already persisted by something else (the * version-history "Restore" action already wrote the restored data to * Supabase) — just reflects it in the currently open canvas, without * re-triggering an import-style "treat this as a brand new diagram". */ applyRestoredDiagram: (diagram: Diagram, snapshotId: string) => void /** Switches the active diagram to another one of the user's own. */ switchToDiagram: (id: string) => Promise /** Permanently deletes a diagram. If it's the currently open one, falls * back to another existing diagram, or a fresh empty one if none remain. */ deleteDiagram: (id: string) => Promise selectDevice: (id: string | null) => void selectConnection: (id: string | null) => void addDeviceFromTemplate: (catalog: Catalog, templateId: string, position: { x: number; y: number }) => void updateDevicePosition: (id: string, position: { x: number; y: number }) => void updateDevice: ( id: string, patch: Partial>, ) => void removeDevice: (id: string) => void /** Returns the new port's id, so the caller can move keyboard focus to it. */ addPort: (deviceId: string, port: Omit) => string /** Inserts a copy of the port right after the original (name auto-incremented) and * returns the new port's id, so the caller can move keyboard focus to it. */ clonePort: (deviceId: string, portId: string) => string updatePort: (deviceId: string, portId: string, patch: Partial>) => void removePort: (deviceId: string, portId: string) => void addConnection: ( catalog: Catalog, params: { sourceDeviceId: string sourcePortId: string targetDeviceId: string targetPortId: string cableTypeId: string }, ) => boolean updateConnection: ( id: string, patch: Partial>, ) => void removeConnection: (id: string) => void /** Creates a new bundle containing just this one connection — the first * leg of a stereo pair, snake, etc. Further legs join via * addConnectionToBundle. */ startBundle: (connectionId: string, fields: Omit) => void addConnectionToBundle: (connectionId: string, bundleId: string) => void /** Clears this connection's bundleId; deletes the bundle itself if that * was its last remaining member. */ removeConnectionFromBundle: (connectionId: string) => void updateBundle: (bundleId: string, patch: Partial>) => void } function touch(diagram: Diagram): Diagram { return { ...diagram, updatedAt: new Date().toISOString() } } /** Drops any bundle no connection references anymore — called after * anything that could zero out a bundle's membership (removing a * connection, or un-bundling its last remaining leg), so an empty bundle * never lingers in the diagram for a user to stumble on later. */ function pruneEmptyBundles(diagram: Diagram): Diagram { const referencedIds = new Set(diagram.connections.map((c) => c.bundleId).filter((id): id is string => !!id)) const bundles = diagram.bundles.filter((b) => referencedIds.has(b.id)) return bundles.length === diagram.bundles.length ? diagram : { ...diagram, bundles } } export const useDiagramStore = create((set, get) => ({ diagram: createEmptyDiagram(), isLoaded: false, diagramList: [], access: null, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null, selectedDeviceId: null, selectedConnectionId: null, lastConnectionError: null, clearConnectionError: () => set({ lastConnectionError: null }), loadInitialDiagram: async () => { const lastId = getLastDiagramId() const loaded = lastId ? await repository.loadById(lastId) : null const diagram = loaded ?? (await loadMostRecent()) ?? createEmptyDiagram() setLastDiagramId(diagram.id) const access = await repository.getAccess(diagram.id) set({ diagram, access, isLoaded: true, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null }) get().refreshDiagramList() }, refreshDiagramList: async () => { const diagramList = await repository.list() set({ diagramList }) }, newDiagram: async (name = 'Untitled Diagram') => { const diagram = createEmptyDiagram(name) setLastDiagramId(diagram.id) set({ diagram, selectedDeviceId: null, selectedConnectionId: null, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null, }) await repository.save(diagram) const access = await repository.getAccess(diagram.id) set({ access }) get().refreshDiagramList() }, renameDiagram: (name) => { set((state) => ({ diagram: touch({ ...state.diagram, name }) })) }, applyRestoredDiagram: (diagram, snapshotId) => { set({ diagram, selectedDeviceId: null, selectedConnectionId: null, restoredFromSnapshotId: snapshotId, restoredDiagramUpdatedAt: diagram.updatedAt, }) }, importDiagram: async (diagram) => { const fresh = touch({ ...diagram, id: uuid() }) setLastDiagramId(fresh.id) set({ diagram: fresh, selectedDeviceId: null, selectedConnectionId: null, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null, }) await repository.save(fresh) const access = await repository.getAccess(fresh.id) set({ access }) get().refreshDiagramList() }, switchToDiagram: async (id) => { if (id === get().diagram.id) return const loaded = await repository.loadById(id) if (!loaded) return setLastDiagramId(loaded.id) const access = await repository.getAccess(loaded.id) set({ diagram: loaded, access, selectedDeviceId: null, selectedConnectionId: null, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null, }) }, deleteDiagram: async (id) => { await repository.deleteById(id) if (get().diagram.id === id) { const next = (await loadMostRecent()) ?? createEmptyDiagram() setLastDiagramId(next.id) const access = await repository.getAccess(next.id) set({ diagram: next, access, selectedDeviceId: null, selectedConnectionId: null, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null, }) } get().refreshDiagramList() }, selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }), selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }), addDeviceFromTemplate: (catalog, templateId, position) => { const { diagram } = get() const template = allDeviceTemplates(catalog).find((t) => t.id === templateId) if (!template) return const device = createDeviceFromTemplate(template, position) set({ diagram: touch({ ...diagram, devices: [...diagram.devices, device] }), selectedDeviceId: device.id }) }, updateDevicePosition: (id, position) => { set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, position } : d)), }), })) }, updateDevice: (id, patch) => { set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)), }), })) }, removeDevice: (id) => { set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.filter((d) => d.id !== id), connections: state.diagram.connections.filter( (c) => c.sourceDeviceId !== id && c.targetDeviceId !== id, ), }), selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId, })) }, addPort: (deviceId, port) => { const newId = uuid() set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d, ), }), })) return newId }, updatePort: (deviceId, portId, patch) => { set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => d.id === deviceId ? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) } : d, ), }), })) }, clonePort: (deviceId, portId) => { const newId = uuid() set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => { if (d.id !== deviceId) return d const index = d.ports.findIndex((p) => p.id === portId) if (index === -1) return d const source = d.ports[index] const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) } return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] } }), }), })) return newId }, removePort: (deviceId, portId) => { set((state) => ({ diagram: touch({ ...state.diagram, devices: state.diagram.devices.map((d) => d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d, ), connections: state.diagram.connections.filter( (c) => c.sourcePortId !== portId && c.targetPortId !== portId, ), }), })) }, addConnection: (catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => { const { diagram } = get() const validation = validateConnection(diagram, catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, }) if (!validation.valid) { set({ lastConnectionError: validation.reason ?? 'Invalid connection.' }) return false } const connection: Connection = { id: uuid(), sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId, } set({ diagram: touch({ ...diagram, connections: [...diagram.connections, connection] }), lastConnectionError: null, selectedConnectionId: connection.id, selectedDeviceId: null, }) return true }, updateConnection: (id, patch) => { set((state) => ({ diagram: touch({ ...state.diagram, connections: state.diagram.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)), }), })) }, removeConnection: (id) => { set((state) => ({ diagram: touch( pruneEmptyBundles({ ...state.diagram, connections: state.diagram.connections.filter((c) => c.id !== id), }), ), selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId, })) }, startBundle: (connectionId, fields) => { const bundle: CableBundle = { id: uuid(), ...fields } set((state) => ({ diagram: touch({ ...state.diagram, bundles: [...state.diagram.bundles, bundle], connections: state.diagram.connections.map((c) => c.id === connectionId ? { ...c, bundleId: bundle.id } : c, ), }), })) }, addConnectionToBundle: (connectionId, bundleId) => { set((state) => ({ diagram: touch({ ...state.diagram, connections: state.diagram.connections.map((c) => (c.id === connectionId ? { ...c, bundleId } : c)), }), })) }, removeConnectionFromBundle: (connectionId) => { set((state) => ({ diagram: touch( pruneEmptyBundles({ ...state.diagram, connections: state.diagram.connections.map((c) => c.id === connectionId ? { ...c, bundleId: undefined } : c, ), }), ), })) }, updateBundle: (bundleId, patch) => { set((state) => ({ diagram: touch({ ...state.diagram, bundles: state.diagram.bundles.map((b) => (b.id === bundleId ? { ...b, ...patch } : b)), }), })) }, })) // Debounced autosave: fires whenever `diagram` changes (skipped until the // initial load completes, so we don't immediately overwrite storage with a // blank diagram on first mount). let saveTimeout: ReturnType | undefined useDiagramStore.subscribe((state, prevState) => { // Skip the isLoaded:false -> true transition itself (that's the initial // load, not a user edit) and any tick where `diagram` didn't change. if (!prevState.isLoaded) return if (state.diagram === prevState.diagram) return // Defense in depth: the UI shouldn't expose any mutating control to a // view-only collaborator in the first place (see FlowCanvas/inspectors), // but skip the write here too rather than relying on RLS's silent no-op. if (state.access?.myPermission === 'view') return if (saveTimeout) clearTimeout(saveTimeout) saveTimeout = setTimeout(() => { repository.save(state.diagram).catch((err) => console.error('Autosave failed', err)) }, 400) }) export type { DeviceCategory }