Rename Project to Diagram throughout the app
Per organized-ideas.md §8: the storage layer (DiagramRepository etc.) was already renamed in an earlier phase; this finishes it everywhere else. - domain/types.ts: Project -> Diagram. domain/project.ts -> domain/diagram.ts (createEmptyProject -> createEmptyDiagram, default name "Untitled Diagram"). - domain/compatibility.ts, domain/bom.ts: Project param/type -> Diagram. - data/exportImport.ts: ProjectImportError -> DiagramImportError, projectToJson/downloadProjectFile/readProjectFile/normalizeProject -> their Diagram equivalents. - state/projectStore.ts -> state/diagramStore.ts: useProjectStore -> useDiagramStore, the `project` field -> `diagram`, newProject/renameProject/ importProject/applyRestoredProject -> *Diagram, restoredProjectUpdatedAt -> restoredDiagramUpdatedAt. - Every component updated to match, compiler-guided (tsc -b enumerated each remaining call site after the core rename, the same approach used for the earlier catalog-parameter refactor). - README updated for the terminology, and to match the repository class names (which had already been renamed but the README hadn't caught up). No backend/schema changes: the JSON shape stored in diagrams.data never changed, only TypeScript-side identifiers, so existing diagrams are unaffected. One SQL comment fixed for accuracy (no migration needed). Verified: tsc -b and oxlint clean; grepped src/ for any remaining Project/project reference (none) after the sweep.
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
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 { 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' | 'manufacturer' | '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
|
||||
}
|
||||
|
||||
function touch(diagram: Diagram): Diagram {
|
||||
return { ...diagram, updatedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
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({
|
||||
...state.diagram,
|
||||
connections: state.diagram.connections.filter((c) => c.id !== id),
|
||||
}),
|
||||
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
|
||||
}))
|
||||
},
|
||||
|
||||
}))
|
||||
|
||||
// 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 }
|
||||
Reference in New Issue
Block a user