import { v4 as uuid } from 'uuid' import { create } from 'zustand' import { validateConnection } from '../domain/compatibility' import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project' import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types' import type { DiagramRepository } 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() interface ProjectStoreState { project: Project isLoaded: boolean selectedDeviceId: string | null selectedConnectionId: string | null lastConnectionError: string | null clearConnectionError: () => void loadFromStorage: () => Promise newProject: (name?: string) => void renameProject: (name: string) => void importProject: (project: Project) => void selectDevice: (id: string | null) => void selectConnection: (id: string | null) => void addDeviceFromTemplate: (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: (params: { sourceDeviceId: string sourcePortId: string targetDeviceId: string targetPortId: string cableTypeId: string }) => boolean updateConnection: ( id: string, patch: Partial>, ) => void removeConnection: (id: string) => void addCustomDeviceTemplate: (template: Omit) => void updateCustomDeviceTemplate: (id: string, patch: Partial>) => void removeCustomDeviceTemplate: (id: string) => void hideBuiltInDeviceTemplate: (id: string) => void restoreBuiltInDeviceTemplate: (id: string) => void addCustomPortType: (portType: Omit) => void updateCustomPortType: (id: string, patch: Partial>) => void addCustomCableType: (cableType: Omit) => void updateCustomCableType: (id: string, patch: Partial>) => void /** Returns the new category's id, so the caller can select it immediately. */ addCustomDeviceCategory: (name: string) => string } function touch(project: Project): Project { return { ...project, updatedAt: new Date().toISOString() } } export const useProjectStore = create((set, get) => ({ project: createEmptyProject(), isLoaded: false, selectedDeviceId: null, selectedConnectionId: null, lastConnectionError: null, clearConnectionError: () => set({ lastConnectionError: null }), loadFromStorage: async () => { const loaded = await repository.load() set({ project: loaded ?? createEmptyProject(), isLoaded: true }) }, newProject: (name = 'Untitled Project') => { set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null }) }, renameProject: (name) => { set((state) => ({ project: touch({ ...state.project, name }) })) }, importProject: (project) => { set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null }) }, selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }), selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }), addDeviceFromTemplate: (templateId, position) => { const { project } = get() const template = allDeviceTemplates(project).find((t) => t.id === templateId) if (!template) return const device = createDeviceFromTemplate(template, position) set({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id }) }, updateDevicePosition: (id, position) => { set((state) => ({ project: touch({ ...state.project, devices: state.project.devices.map((d) => (d.id === id ? { ...d, position } : d)), }), })) }, updateDevice: (id, patch) => { set((state) => ({ project: touch({ ...state.project, devices: state.project.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)), }), })) }, removeDevice: (id) => { set((state) => ({ project: touch({ ...state.project, devices: state.project.devices.filter((d) => d.id !== id), connections: state.project.connections.filter( (c) => c.sourceDeviceId !== id && c.targetDeviceId !== id, ), }), selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId, })) }, addPort: (deviceId, port) => { const newId = uuid() set((state) => ({ project: touch({ ...state.project, devices: state.project.devices.map((d) => d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d, ), }), })) return newId }, updatePort: (deviceId, portId, patch) => { set((state) => ({ project: touch({ ...state.project, devices: state.project.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) => ({ project: touch({ ...state.project, devices: state.project.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) => ({ project: touch({ ...state.project, devices: state.project.devices.map((d) => d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d, ), connections: state.project.connections.filter( (c) => c.sourcePortId !== portId && c.targetPortId !== portId, ), }), })) }, addConnection: ({ sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => { const { project } = get() const validation = validateConnection(project, { 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({ project: touch({ ...project, connections: [...project.connections, connection] }), lastConnectionError: null, selectedConnectionId: connection.id, selectedDeviceId: null, }) return true }, updateConnection: (id, patch) => { set((state) => ({ project: touch({ ...state.project, connections: state.project.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)), }), })) }, removeConnection: (id) => { set((state) => ({ project: touch({ ...state.project, connections: state.project.connections.filter((c) => c.id !== id), }), selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId, })) }, addCustomDeviceTemplate: (template) => { set((state) => ({ project: touch({ ...state.project, customDeviceTemplates: [ ...state.project.customDeviceTemplates, { ...template, id: uuid() }, ], }), })) }, updateCustomDeviceTemplate: (id, patch) => { set((state) => ({ project: touch({ ...state.project, customDeviceTemplates: state.project.customDeviceTemplates.map((t) => t.id === id ? { ...t, ...patch } : t, ), }), })) }, removeCustomDeviceTemplate: (id) => { set((state) => ({ project: touch({ ...state.project, customDeviceTemplates: state.project.customDeviceTemplates.filter((t) => t.id !== id), }), })) }, hideBuiltInDeviceTemplate: (id) => { set((state) => ({ project: touch({ ...state.project, hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.includes(id) ? state.project.hiddenBuiltInDeviceTemplateIds : [...state.project.hiddenBuiltInDeviceTemplateIds, id], }), })) }, restoreBuiltInDeviceTemplate: (id) => { set((state) => ({ project: touch({ ...state.project, hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.filter((h) => h !== id), }), })) }, addCustomPortType: (portType) => { set((state) => ({ project: touch({ ...state.project, customPortTypes: [...state.project.customPortTypes, { ...portType, id: uuid(), custom: true }], }), })) }, updateCustomPortType: (id, patch) => { set((state) => ({ project: touch({ ...state.project, customPortTypes: state.project.customPortTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)), }), })) }, addCustomCableType: (cableType) => { set((state) => ({ project: touch({ ...state.project, customCableTypes: [...state.project.customCableTypes, { ...cableType, id: uuid(), custom: true }], }), })) }, updateCustomCableType: (id, patch) => { set((state) => ({ project: touch({ ...state.project, customCableTypes: state.project.customCableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)), }), })) }, addCustomDeviceCategory: (name) => { const newId = uuid() set((state) => ({ project: touch({ ...state.project, customDeviceCategories: [ ...state.project.customDeviceCategories, { id: newId, name, custom: true }, ], }), })) return newId }, })) // Debounced autosave: fires whenever `project` changes (skipped until the // initial load completes, so we don't immediately overwrite storage with a // blank project on first mount). let saveTimeout: ReturnType | undefined useProjectStore.subscribe((state, prevState) => { // Skip the isLoaded:false -> true transition itself (that's the initial // load, not a user edit) and any tick where `project` didn't change. if (!prevState.isLoaded) return if (state.project === prevState.project) return if (saveTimeout) clearTimeout(saveTimeout) saveTimeout = setTimeout(() => { repository.save(state.project).catch((err) => console.error('Autosave failed', err)) }, 400) }) export type { DeviceCategory }