Canvas devices can now collapse to a compact per-direction-group view; when collapsed, cables between the same two devices of the same type merge into one dashed "virtual" edge with a ×N marker so a busy rig doesn't turn into a wall of parallel lines. Adds CableBundle so several real Connections (a stereo RCA pair, a snake's channels) can be grouped and bought as one BOM line while keeping each leg's own family-checked cable type intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
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<Diagram | null> {
|
|
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<void>
|
|
refreshDiagramList: () => Promise<void>
|
|
/** 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<void>
|
|
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<void>
|
|
/** 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<void>
|
|
/** 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<void>
|
|
|
|
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<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
|
|
) => 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<Port, 'id'>) => 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<Omit<Port, 'id'>>) => 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<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
|
) => 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<CableBundle, 'id'>) => 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<Omit<CableBundle, 'id'>>) => 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<DiagramStoreState>((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<typeof setTimeout> | 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 }
|