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:
2026-09-11 12:10:11 -05:00
parent cbbe96f575
commit 26f41b3d4a
34 changed files with 292 additions and 293 deletions
+15 -12
View File
@@ -18,13 +18,14 @@ src/
domain/ Framework-agnostic types + logic (compatibility rules, BOM math,
the built-in connector/cable/device library). No React, no
React Flow — safe to unit test or reuse from a future backend.
data/ Storage abstraction. `ProjectRepository` is the interface the
rest of the app codes against; `LocalStorageProjectRepository`
is the only implementation today. Swapping in a real backend
later (REST/GraphQL) means adding one new implementation of
that interface, not touching state/UI code.
state/ Zustand store (`projectStore`) — the single source of truth for
the current project. Auto-saves to the repository (debounced)
data/ Storage abstraction. `DiagramRepository` is the interface the
rest of the app codes against; `SupabaseDiagramRepository` is
the active implementation, with `LocalStorageDiagramRepository`
kept as a reference/fallback. Swapping storage means adding
one new implementation of that interface, not touching
state/UI code.
state/ Zustand store (`diagramStore`) — the single source of truth for
the current diagram. Auto-saves to the repository (debounced)
on every change.
components/
canvas/ React Flow wiring surface: custom device node, custom cable
@@ -40,17 +41,19 @@ src/
- **PortType** — a connector kind (HDMI, XLR, Cat5e/6, ...). Two ports can be
wired together only if their PortTypes share a compatibility `family`.
- **DeviceTemplate** — a reusable device shape (its ports), shown in the
palette. Built-ins live in `domain/library.ts`; custom ones are saved on the
project.
palette. Built-ins live in `domain/library.ts` and seed the public catalog;
custom ones are your own private catalog entries (or submitted for public
review).
- **Device** — an instance placed on the canvas, with its own copy of ports
(editing an instance never mutates its template).
- **CableType** — a physical cable spec, scoped to a PortType family.
- **Connection** — a wire between two ports, referencing a CableType and an
optional user-entered distance (there's no floor plan / scale model, so
length is a manual estimate per connection).
- **Project** — devices + connections + any custom library entries. This is
the one object persisted (auto-saved to `localStorage`, or explicitly
exported/imported as JSON).
- **Diagram** — devices + connections. This is the one object persisted
(auto-saved to Supabase, or explicitly exported/imported as JSON); the
catalog (PortType/CableType/DeviceTemplate/category) is shared and global,
not part of any one diagram.
## Development
+3 -3
View File
@@ -1,7 +1,7 @@
import { Fragment, useMemo, useState } from 'react'
import { computeBom } from '../../domain/bom'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import Chevron from '../common/Chevron'
function formatCost(value: number): string {
@@ -9,9 +9,9 @@ function formatCost(value: number): string {
}
export default function BomPanel() {
const project = useProjectStore((s) => s.project)
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const bom = useMemo(() => computeBom(project, catalog), [project, catalog])
const bom = useMemo(() => computeBom(diagram, catalog), [diagram, catalog])
// Collapsed by default, per cable type — most diagrams only need the
// per-type totals; the length breakdown is there when you're ready to
// actually go buy cables.
+23 -23
View File
@@ -15,9 +15,9 @@ import {
import '@xyflow/react/dist/style.css'
import { useCallback, useMemo } from 'react'
import { validateConnection } from '../../domain/compatibility'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/project'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildHandleId, parsePortId } from './handleIds'
@@ -28,19 +28,19 @@ const nodeTypes: NodeTypes = { device: DeviceNode }
const edgeTypes: EdgeTypes = { cable: CableEdge }
function FlowCanvasInner() {
const project = useProjectStore((s) => s.project)
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const updateDevicePosition = useProjectStore((s) => s.updateDevicePosition)
const removeDevice = useProjectStore((s) => s.removeDevice)
const removeConnection = useProjectStore((s) => s.removeConnection)
const addConnection = useProjectStore((s) => s.addConnection)
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const selectDevice = useProjectStore((s) => s.selectDevice)
const selectConnection = useProjectStore((s) => s.selectConnection)
const diagram = useDiagramStore((s) => s.diagram)
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
const selectedConnectionId = useDiagramStore((s) => s.selectedConnectionId)
const updateDevicePosition = useDiagramStore((s) => s.updateDevicePosition)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const addConnection = useDiagramStore((s) => s.addConnection)
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const selectDevice = useDiagramStore((s) => s.selectDevice)
const selectConnection = useDiagramStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow()
const catalog = useCatalogStore((s) => s.catalog)
const access = useProjectStore((s) => s.access)
const access = useDiagramStore((s) => s.access)
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
// 'edit') can edit; a view-only collaborator can look but not touch —
// organized-ideas.md §8's per-collaborator permissions. `access` is only
@@ -53,10 +53,10 @@ function FlowCanvasInner() {
const nodes: DeviceFlowNode[] = useMemo(
() =>
project.devices.map((device) => {
diagram.devices.map((device) => {
const portInfos: PortInfo[] = device.ports.map((port) => {
const portType = portTypes.find((pt) => pt.id === port.portTypeId)
const connectionCount = project.connections.filter(
const connectionCount = diagram.connections.filter(
(c) => c.sourcePortId === port.id || c.targetPortId === port.id,
).length
const max = portType?.maxConnections ?? 1
@@ -70,12 +70,12 @@ function FlowCanvasInner() {
data: { device, categoryName: deviceCategoryName(catalog, device.category), portInfos },
}
}),
[project, catalog, portTypes, selectedDeviceId],
[diagram, catalog, portTypes, selectedDeviceId],
)
const edges: CableFlowEdge[] = useMemo(
() =>
project.connections.map((connection) => {
diagram.connections.map((connection) => {
const cableType = cableTypes.find((ct) => ct.id === connection.cableTypeId)
return {
id: connection.id,
@@ -92,7 +92,7 @@ function FlowCanvasInner() {
},
}
}),
[project.connections, cableTypes, selectedConnectionId],
[diagram.connections, cableTypes, selectedConnectionId],
)
const isValidConnection = useCallback(
@@ -101,14 +101,14 @@ function FlowCanvasInner() {
const sourcePortId = parsePortId(c.sourceHandle)
const targetPortId = parsePortId(c.targetHandle)
if (!canEdit || !c.source || !c.target || !sourcePortId || !targetPortId) return false
return validateConnection(project, catalog, {
return validateConnection(diagram, catalog, {
sourceDeviceId: c.source,
sourcePortId,
targetDeviceId: c.target,
targetPortId,
}).valid
},
[project, catalog, canEdit],
[diagram, catalog, canEdit],
)
const onConnect = useCallback(
@@ -117,8 +117,8 @@ function FlowCanvasInner() {
const sourcePortId = parsePortId(connection.sourceHandle)
const targetPortId = parsePortId(connection.targetHandle)
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
const sourceDevice = project.devices.find((d) => d.id === connection.source)
const targetDevice = project.devices.find((d) => d.id === connection.target)
const sourceDevice = diagram.devices.find((d) => d.id === connection.source)
const targetDevice = diagram.devices.find((d) => d.id === connection.target)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
const sourcePortType = sourcePort && getPortType(catalog, sourcePort.portTypeId)
@@ -134,7 +134,7 @@ function FlowCanvasInner() {
cableTypeId: defaultCableType.id,
})
},
[project, catalog, canEdit, addConnection],
[diagram, catalog, canEdit, addConnection],
)
const onNodesChange = useCallback(
+1 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { allDeviceCategories } from '../../domain/project'
import { allDeviceCategories } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
const NEW_CATEGORY_VALUE = '__new__'
@@ -1,19 +1,19 @@
import { useMemo } from 'react'
import { cableTypesForConnection, getPortType } from '../../domain/project'
import { cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
const project = useProjectStore((s) => s.project)
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useProjectStore((s) => s.access)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const updateConnection = useProjectStore((s) => s.updateConnection)
const removeConnection = useProjectStore((s) => s.removeConnection)
const updateConnection = useDiagramStore((s) => s.updateConnection)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const connection = project.connections.find((c) => c.id === connectionId)
const sourceDevice = project.devices.find((d) => d.id === connection?.sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === connection?.targetDeviceId)
const connection = diagram.connections.find((c) => c.id === connectionId)
const sourceDevice = diagram.devices.find((d) => d.id === connection?.sourceDeviceId)
const targetDevice = diagram.devices.find((d) => d.id === connection?.targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === connection?.sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === connection?.targetPortId)
+12 -12
View File
@@ -1,25 +1,25 @@
import { useMemo, useState } from 'react'
import { PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes } from '../../domain/project'
import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import CategorySelect from '../common/CategorySelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const project = useProjectStore((s) => s.project)
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useProjectStore((s) => s.access)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const updateDevice = useProjectStore((s) => s.updateDevice)
const removeDevice = useProjectStore((s) => s.removeDevice)
const addPort = useProjectStore((s) => s.addPort)
const clonePort = useProjectStore((s) => s.clonePort)
const updatePort = useProjectStore((s) => s.updatePort)
const removePort = useProjectStore((s) => s.removePort)
const updateDevice = useDiagramStore((s) => s.updateDevice)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const addPort = useDiagramStore((s) => s.addPort)
const clonePort = useDiagramStore((s) => s.clonePort)
const updatePort = useDiagramStore((s) => s.updatePort)
const removePort = useDiagramStore((s) => s.removePort)
const [focusPortId, setFocusPortId] = useState<string | null>(null)
const device = project.devices.find((d) => d.id === deviceId)
const device = diagram.devices.find((d) => d.id === deviceId)
const portTypes = useMemo(
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
@@ -28,7 +28,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
if (!device) return null
const connectionCountForPort = (portId: string) =>
project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
diagram.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
return (
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import FlowCanvas from '../canvas/FlowCanvas'
import DevicePalette from '../palette/DevicePalette'
@@ -12,8 +12,8 @@ import TopBar from './TopBar'
import ViewOnlyBanner from './ViewOnlyBanner'
export default function AppShell() {
const isLoaded = useProjectStore((s) => s.isLoaded)
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
const isLoaded = useDiagramStore((s) => s.isLoaded)
const loadInitialDiagram = useDiagramStore((s) => s.loadInitialDiagram)
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions)
@@ -1,9 +1,9 @@
import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
export default function ConnectionErrorBanner() {
const error = useProjectStore((s) => s.lastConnectionError)
const clear = useProjectStore((s) => s.clearConnectionError)
const error = useDiagramStore((s) => s.lastConnectionError)
const clear = useDiagramStore((s) => s.clearConnectionError)
useEffect(() => {
if (!error) return
+10 -10
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
function formatUpdatedAt(iso: string): string {
@@ -8,25 +8,25 @@ function formatUpdatedAt(iso: string): string {
}
export default function DiagramManagerModal({ onClose }: { onClose: () => void }) {
const project = useProjectStore((s) => s.project)
const diagramList = useProjectStore((s) => s.diagramList)
const refreshDiagramList = useProjectStore((s) => s.refreshDiagramList)
const newProject = useProjectStore((s) => s.newProject)
const switchToDiagram = useProjectStore((s) => s.switchToDiagram)
const deleteDiagram = useProjectStore((s) => s.deleteDiagram)
const diagram = useDiagramStore((s) => s.diagram)
const diagramList = useDiagramStore((s) => s.diagramList)
const refreshDiagramList = useDiagramStore((s) => s.refreshDiagramList)
const newDiagram = useDiagramStore((s) => s.newDiagram)
const switchToDiagram = useDiagramStore((s) => s.switchToDiagram)
const deleteDiagram = useDiagramStore((s) => s.deleteDiagram)
useEffect(() => {
refreshDiagramList()
}, [refreshDiagramList])
const handleOpen = async (id: string) => {
if (id === project.id) return
if (id === diagram.id) return
await switchToDiagram(id)
onClose()
}
const handleNew = async () => {
await newProject()
await newDiagram()
onClose()
}
@@ -52,7 +52,7 @@ export default function DiagramManagerModal({ onClose }: { onClose: () => void }
<ul className="space-y-1">
{diagramList.map((d) => {
const isCurrent = d.id === project.id
const isCurrent = d.id === diagram.id
return (
<li
key={d.id}
+3 -3
View File
@@ -2,13 +2,13 @@ import { useState } from 'react'
import BomPanel from '../bom/BomPanel'
import ConnectionInspector from '../inspector/ConnectionInspector'
import DeviceInspector from '../inspector/DeviceInspector'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
type Tab = 'inspector' | 'bom'
export default function RightPanel() {
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
const selectedConnectionId = useDiagramStore((s) => s.selectedConnectionId)
const [tab, setTab] = useState<Tab>('inspector')
return (
+12 -12
View File
@@ -1,9 +1,9 @@
import { useMemo, useRef, useState } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { downloadDiagramFile, readDiagramFile, DiagramImportError } from '../../data/exportImport'
import { supabase } from '../../data/supabaseClient'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import AdminUsersModal from '../admin/AdminUsersModal'
import DiagramSharingModal from '../sharing/DiagramSharingModal'
@@ -33,11 +33,11 @@ function setLastSeenSubmissionsAt(iso: string): void {
}
export default function TopBar() {
const project = useProjectStore((s) => s.project)
const access = useProjectStore((s) => s.access)
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const renameProject = useProjectStore((s) => s.renameProject)
const importProject = useProjectStore((s) => s.importProject)
const renameDiagram = useDiagramStore((s) => s.renameDiagram)
const importDiagram = useDiagramStore((s) => s.importDiagram)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const username = useAuthStore((s) => s.username)
const role = useAuthStore((s) => s.role)
@@ -66,7 +66,7 @@ export default function TopBar() {
setLastSeenSubmissionsAt(new Date().toISOString())
}
const handleExport = () => downloadProjectFile(project)
const handleExport = () => downloadDiagramFile(diagram)
const handleImportClick = () => fileInputRef.current?.click()
@@ -75,10 +75,10 @@ export default function TopBar() {
event.target.value = ''
if (!file) return
try {
const imported = await readProjectFile(file)
await importProject(imported)
const imported = await readDiagramFile(file)
await importDiagram(imported)
} catch (err) {
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.'
const message = err instanceof DiagramImportError ? err.message : 'Could not read that file.'
window.alert(`Import failed: ${message}`)
}
}
@@ -88,8 +88,8 @@ export default function TopBar() {
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">AV Planner</span>
<input
value={project.name}
onChange={(e) => renameProject(e.target.value)}
value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)}
disabled={!canEdit}
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
+2 -2
View File
@@ -1,4 +1,4 @@
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
/** Shown when the current user only has view access to the open diagram
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and
@@ -6,7 +6,7 @@ import { useProjectStore } from '../../state/projectStore'
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that
* visible instead of leaving it to be discovered by a control not working. */
export default function ViewOnlyBanner() {
const access = useProjectStore((s) => s.access)
const access = useDiagramStore((s) => s.access)
if (access?.myPermission !== 'view') return null
return (
+1 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { allCableTypes, allPortTypes } from '../../domain/project'
import { allCableTypes, allPortTypes } from '../../domain/diagram'
import type { CableType, PortType } from '../../domain/types'
import { cableTypeToRow } from '../../data/catalogRowMapping'
import { useAdminReviewStore } from '../../state/adminReviewStore'
+6 -6
View File
@@ -1,11 +1,11 @@
import { useMemo, useState } from 'react'
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project'
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/diagram'
import type { DeviceTemplate } from '../../domain/types'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import Chevron from '../common/Chevron'
@@ -19,10 +19,10 @@ type EditorTarget =
| { mode: 'adminEdit'; template: DeviceTemplate }
export default function DevicePalette() {
const project = useProjectStore((s) => s.project)
const access = useProjectStore((s) => s.access)
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const catalog = useCatalogStore((s) => s.catalog)
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
@@ -81,7 +81,7 @@ export default function DevicePalette() {
const handleQuickAdd = (template: DeviceTemplate) => {
if (!canEdit) return
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
const offset = (project.devices.length % 8) * 24
const offset = (diagram.devices.length % 8) * 24
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
}
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram'
import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { PORT_CATEGORIES } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/project'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram'
import type { PortType } from '../../domain/types'
import { portTypeToRow } from '../../data/catalogRowMapping'
import { useAdminReviewStore } from '../../state/adminReviewStore'
@@ -2,14 +2,14 @@ import { useEffect, useState } from 'react'
import type { CollaboratorPermission } from '../../data/DiagramCollaboratorRepository'
import { SelfCollaboratorError, UnknownUsernameError } from '../../data/DiagramCollaboratorRepository'
import { useDiagramCollaboratorStore } from '../../state/diagramCollaboratorStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
export default function DiagramSharingModal({ onClose }: { onClose: () => void }) {
const project = useProjectStore((s) => s.project)
const access = useProjectStore((s) => s.access)
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const isOwner = access?.myPermission === 'owner'
const diagramId = project.id
const diagramId = diagram.id
const collaborators = useDiagramCollaboratorStore((s) => s.collaborators)
const isLoaded = useDiagramCollaboratorStore((s) => s.isLoaded)
@@ -52,7 +52,7 @@ export default function DiagramSharingModal({ onClose }: { onClose: () => void }
}
return (
<Modal title={`Share "${project.name}"`} onClose={onClose} width="max-w-md">
<Modal title={`Share "${diagram.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-3">
{isOwner && (
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useDiagramSnapshotStore } from '../../state/diagramSnapshotStore'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
function formatDateTime(iso: string): string {
@@ -8,10 +8,10 @@ function formatDateTime(iso: string): string {
}
export default function VersionHistoryModal({ onClose }: { onClose: () => void }) {
const project = useProjectStore((s) => s.project)
const access = useProjectStore((s) => s.access)
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canRestore = access?.myPermission === 'owner' || access?.myPermission === 'edit'
const diagramId = project.id
const diagramId = diagram.id
const snapshots = useDiagramSnapshotStore((s) => s.snapshots)
const isLoaded = useDiagramSnapshotStore((s) => s.isLoaded)
@@ -37,7 +37,7 @@ export default function VersionHistoryModal({ onClose }: { onClose: () => void }
}
return (
<Modal title={`Version History — "${project.name}"`} onClose={onClose} width="max-w-md">
<Modal title={`Version History — "${diagram.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-1.5">
<p className="text-[11px] text-slate-500">
Checkpoints saved periodically while editing (up to the 50 most recent).
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { cableTypeToRow, portTypeToRow, rowToCableTypeFields, rowToDeviceTemplateFields, rowToPortTypeFields } from '../../data/catalogRowMapping'
import type { CatalogSubmission } from '../../data/SubmissionRepository'
import { allPortTypes } from '../../domain/project'
import { allPortTypes } from '../../domain/diagram'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
+9 -15
View File
@@ -1,4 +1,4 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
/** Lightweight metadata for listing diagrams without fetching each one's
* full (potentially large) data payload. */
@@ -8,17 +8,6 @@ export interface DiagramSummary {
updatedAt: string
}
/**
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
* know or care which one is active.
*
* Named for the "Diagram" terminology decided in organized-ideas.md §8 (renamed from
* "Project"). This is a deliberately scoped rename: only the storage layer's naming
* changed here. The domain type (`Project`), the Zustand store (`useProjectStore`), and
* user-facing copy still say "Project" — that's a separate, larger mechanical rename
* across the whole app, tracked as its own task rather than bundled into this one.
*/
/** Your relationship to a diagram — who owns it, and what you personally
* can do with it (organized-ideas.md §8's per-collaborator view/edit
* permissions). Drives the frontend's own view-only lockdown, since RLS
@@ -29,13 +18,18 @@ export interface DiagramAccess {
myPermission: 'owner' | 'edit' | 'view'
}
/**
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
* know or care which one is active.
*/
export interface DiagramRepository {
/** Every diagram visible to the current user, most recently updated first. */
list(): Promise<DiagramSummary[]>
/** One diagram's full data by id, or null if it doesn't exist / isn't visible. */
loadById(id: string): Promise<Project | null>
/** Upsert a diagram, matched by the project's own id. */
save(project: Project): Promise<void>
loadById(id: string): Promise<Diagram | null>
/** Upsert a diagram, matched by its own id. */
save(diagram: Diagram): Promise<void>
/** Permanently delete one diagram by id. */
deleteById(id: string): Promise<void>
/** Who owns this diagram and what the current user can do with it. */
+3 -3
View File
@@ -1,4 +1,4 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
export interface DiagramSnapshotSummary {
id: string
@@ -20,7 +20,7 @@ export interface DiagramSnapshotRepository {
* it already knows the live state is an unmodified copy of a snapshot
* that still exists (e.g. restoring again right after an earlier
* restore, with no edits in between), so a second copy of it would just
* be a duplicate. Returns the restored Project so the caller can update
* be a duplicate. Returns the restored Diagram so the caller can update
* the open diagram in place. */
restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Project | null>
restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Diagram | null>
}
+12 -11
View File
@@ -1,10 +1,10 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import { normalizeDiagram } from './exportImport'
const STORAGE_KEY = 'av-planner:diagrams:v1'
type StoredDiagrams = Record<string, Project>
type StoredDiagrams = Record<string, Diagram>
function readAll(): StoredDiagrams {
try {
@@ -20,10 +20,11 @@ function writeAll(diagrams: StoredDiagrams): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(diagrams))
}
/** Kept around (no longer the active repository — see projectStore.ts) as a
* reference implementation and a safe fallback if Supabase is unreachable.
* Multiple diagrams are stored as one JSON object keyed by id, matching the
* shape the Supabase-backed repository presents to the rest of the app. */
/** Kept around (no longer the active repository — see state/diagramStore.ts)
* as a reference implementation and a safe fallback if Supabase is
* unreachable. Multiple diagrams are stored as one JSON object keyed by
* id, matching the shape the Supabase-backed repository presents to the
* rest of the app. */
export class LocalStorageDiagramRepository implements DiagramRepository {
async list(): Promise<DiagramSummary[]> {
const diagrams = Object.values(readAll())
@@ -32,14 +33,14 @@ export class LocalStorageDiagramRepository implements DiagramRepository {
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
async loadById(id: string): Promise<Project | null> {
async loadById(id: string): Promise<Diagram | null> {
const diagram = readAll()[id]
return diagram ? normalizeProject(diagram) : null
return diagram ? normalizeDiagram(diagram) : null
}
async save(project: Project): Promise<void> {
async save(diagram: Diagram): Promise<void> {
const all = readAll()
all[project.id] = project
all[diagram.id] = diagram
writeAll(all)
}
+12 -12
View File
@@ -1,11 +1,11 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import { normalizeDiagram } from './exportImport'
import { supabase } from './supabaseClient'
/**
* Backs the app with the local (or eventually production) Supabase
* `diagrams` table instead of localStorage. The whole `Project` domain
* `diagrams` table instead of localStorage. The whole `Diagram` domain
* object is stored as-is in the `data` JSONB column — see the schema
* migration's header comment for why diagrams are a document, not
* normalized relational rows.
@@ -27,7 +27,7 @@ export class SupabaseDiagramRepository implements DiagramRepository {
return (data ?? []).map((row) => ({ id: row.id, name: row.name, updatedAt: row.updated_at }))
}
async loadById(id: string): Promise<Project | null> {
async loadById(id: string): Promise<Diagram | null> {
const { data: row, error } = await supabase.from('diagrams').select('data').eq('id', id).maybeSingle()
if (error) {
@@ -35,10 +35,10 @@ export class SupabaseDiagramRepository implements DiagramRepository {
return null
}
if (!row) return null
return normalizeProject(row.data as Project)
return normalizeDiagram(row.data as Diagram)
}
async save(project: Project): Promise<void> {
async save(diagram: Diagram): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
@@ -52,15 +52,15 @@ export class SupabaseDiagramRepository implements DiagramRepository {
// to whoever saves last — once diagram sharing (§8) is in play, an
// edit-collaborator saving changes must never be able to overwrite who
// owns the diagram.
const { data: existing } = await supabase.from('diagrams').select('id, data').eq('id', project.id).maybeSingle()
const { data: existing } = await supabase.from('diagrams').select('id, data').eq('id', diagram.id).maybeSingle()
if (existing) {
await this.maybeWriteSnapshot(project.id, existing.data as Project, user.id)
await this.maybeWriteSnapshot(diagram.id, existing.data as Diagram, user.id)
}
const payload = { name: project.name, data: project, updated_at: new Date().toISOString() }
const payload = { name: diagram.name, data: diagram, updated_at: new Date().toISOString() }
const { error } = existing
? await supabase.from('diagrams').update(payload).eq('id', project.id)
: await supabase.from('diagrams').insert({ id: project.id, owner_id: user.id, ...payload })
? await supabase.from('diagrams').update(payload).eq('id', diagram.id)
: await supabase.from('diagrams').insert({ id: diagram.id, owner_id: user.id, ...payload })
if (error) {
console.error('Failed to save diagram to Supabase', error)
@@ -73,7 +73,7 @@ export class SupabaseDiagramRepository implements DiagramRepository {
// keystroke-adjacent change instead of meaningful recovery points.
private static readonly SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000
private async maybeWriteSnapshot(diagramId: string, previousData: Project, userId: string): Promise<void> {
private async maybeWriteSnapshot(diagramId: string, previousData: Diagram, userId: string): Promise<void> {
const { data: lastSnapshot } = await supabase
.from('diagram_snapshots')
.select('created_at')
@@ -1,6 +1,6 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from './DiagramSnapshotRepository'
import { normalizeProject } from './exportImport'
import { normalizeDiagram } from './exportImport'
import { supabase } from './supabaseClient'
interface SnapshotRow {
@@ -36,7 +36,7 @@ export class SupabaseDiagramSnapshotRepository implements DiagramSnapshotReposit
}))
}
async restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Project | null> {
async restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Diagram | null> {
const { data: snapshot, error } = await supabase.from('diagram_snapshots').select('data').eq('id', snapshotId).maybeSingle()
if (error) console.error('Failed to load snapshot from Supabase', error)
if (!snapshot) return null
@@ -59,7 +59,7 @@ export class SupabaseDiagramSnapshotRepository implements DiagramSnapshotReposit
}
}
const restored = normalizeProject(snapshot.data as Project)
const restored = normalizeDiagram(snapshot.data as Diagram)
const { error: updateError } = await supabase
.from('diagrams')
.update({ data: restored, name: restored.name, updated_at: new Date().toISOString() })
+22 -22
View File
@@ -1,15 +1,15 @@
import type { Project } from '../domain/types'
import type { Diagram } from '../domain/types'
export function projectToJson(project: Project): string {
return JSON.stringify(project, null, 2)
export function diagramToJson(diagram: Diagram): string {
return JSON.stringify(diagram, null, 2)
}
export function downloadProjectFile(project: Project): void {
const json = projectToJson(project)
export function downloadDiagramFile(diagram: Diagram): void {
const json = diagramToJson(diagram)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const safeName = project.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'project'
const safeName = diagram.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'diagram'
a.href = url
a.download = `${safeName}.avplan.json`
document.body.appendChild(a)
@@ -18,44 +18,44 @@ export function downloadProjectFile(project: Project): void {
URL.revokeObjectURL(url)
}
class ProjectImportError extends Error {}
class DiagramImportError extends Error {}
/** Minimal structural check — enough to catch "wrong file" without pulling
* in a schema validation library for a single call site. */
function assertLooksLikeProject(value: unknown): asserts value is Project {
function assertLooksLikeDiagram(value: unknown): asserts value is Diagram {
if (typeof value !== 'object' || value === null) {
throw new ProjectImportError('File does not contain a JSON object.')
throw new DiagramImportError('File does not contain a JSON object.')
}
const p = value as Partial<Project>
if (typeof p.id !== 'string' || typeof p.name !== 'string') {
throw new ProjectImportError('File is missing required project fields.')
const d = value as Partial<Diagram>
if (typeof d.id !== 'string' || typeof d.name !== 'string') {
throw new DiagramImportError('File is missing required diagram fields.')
}
if (!Array.isArray(p.devices) || !Array.isArray(p.connections)) {
throw new ProjectImportError('File is missing devices/connections arrays.')
if (!Array.isArray(d.devices) || !Array.isArray(d.connections)) {
throw new DiagramImportError('File is missing devices/connections arrays.')
}
}
export async function readProjectFile(file: File): Promise<Project> {
export async function readDiagramFile(file: File): Promise<Diagram> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new ProjectImportError('File is not valid JSON.')
throw new DiagramImportError('File is not valid JSON.')
}
assertLooksLikeProject(parsed)
return normalizeProject(parsed)
assertLooksLikeDiagram(parsed)
return normalizeDiagram(parsed)
}
/** Strips any legacy per-project catalog fields (customDeviceTemplates,
/** Strips any legacy per-diagram catalog fields (customDeviceTemplates,
* customPortTypes, etc.) that might still be present in an older export
* file or a localStorage diagram saved before the catalog moved to
* Supabase (see state/catalogStore.ts) — that data now lives in the shared
* catalog, not the diagram, so it's just discarded here rather than
* crashing on an unexpected shape. */
export function normalizeProject(project: Project): Project {
const { id, name, createdAt, updatedAt, devices, connections } = project
export function normalizeDiagram(diagram: Diagram): Diagram {
const { id, name, createdAt, updatedAt, devices, connections } = diagram
return { id, name, createdAt, updatedAt, devices, connections }
}
export { ProjectImportError }
export { DiagramImportError }
+5 -5
View File
@@ -1,5 +1,5 @@
import { allCableTypes } from './project'
import type { CableType, Catalog, Connection, Device, Project } from './types'
import { allCableTypes } from './diagram'
import type { CableType, Catalog, Connection, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
* is undefined for the bucket of connections that have no length entered
@@ -82,7 +82,7 @@ function connectionCost(connection: Connection, cableType: CableType | undefined
return undefined
}
export function computeBom(project: Project, catalog: Catalog): Bom {
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const cableLines = new Map<string, BomCableLine>()
@@ -92,7 +92,7 @@ export function computeBom(project: Project, catalog: Catalog): Bom {
// addressable by (cableTypeId, distance) while building them up.
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
for (const connection of project.connections) {
for (const connection of diagram.connections) {
const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
@@ -155,7 +155,7 @@ export function computeBom(project: Project, catalog: Catalog): Bom {
}
const deviceLines = new Map<string, BomDeviceLine>()
for (const device of project.devices) {
for (const device of diagram.devices) {
const key = deviceGroupKey(device)
const existing = deviceLines.get(key) ?? {
key,
+6 -6
View File
@@ -1,5 +1,5 @@
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
import type { Catalog, Connection, Device, PortType, Project } from './types'
import { acceptedFamilies, getPortType, hasBridgingCableType } from './diagram'
import type { Catalog, Connection, Device, Diagram, PortType } from './types'
/**
* Two ports connect if the sets of families they each accept (their own
@@ -36,10 +36,10 @@ export interface ConnectionValidation {
* server-side later since it only depends on plain domain data.
*/
export function validateConnection(
project: Project,
diagram: Diagram,
catalog: Catalog,
candidate: ConnectionCandidate,
existingConnections: Connection[] = project.connections,
existingConnections: Connection[] = diagram.connections,
): ConnectionValidation {
const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate
@@ -47,8 +47,8 @@ export function validateConnection(
return { valid: false, reason: 'A port cannot connect to itself.' }
}
const sourceDevice = project.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === targetDeviceId)
const sourceDevice = diagram.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = diagram.devices.find((d) => d.id === targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
+2 -2
View File
@@ -1,7 +1,7 @@
import type { PortCategory, PortDirection } from './types'
// Device categories are no longer a fixed list — see `domain/project.ts`'s
// `allDeviceCategories` (built-ins + project-custom, merged).
// Device categories are no longer a fixed list — see `domain/diagram.ts`'s
// `allDeviceCategories` (public catalog + your own private ones, merged).
export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [
{ value: 'input', label: 'Input' },
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { CableType, Catalog, Device, DeviceCategoryDef, DeviceTemplate, PortType, Project } from './types'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, PortType } from './types'
export function allPortTypes(catalog: Catalog): PortType[] {
return catalog.portTypes
@@ -125,7 +125,7 @@ export function expandCompatibleFamilyIds(selectedPortTypeIds: string[], allType
return [...families]
}
export function createEmptyProject(name = 'Untitled Project'): Project {
export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
const now = new Date().toISOString()
return {
id: uuid(),
+4 -3
View File
@@ -16,11 +16,12 @@ export type PortCategory =
| 'other'
/** A device category id (references a DeviceCategoryDef). Kept as a plain
* string rather than a fixed union so projects can define their own. */
* string rather than a fixed union so users can define their own. */
export type DeviceCategory = string
/** A device grouping shown in the palette and category pickers. Built-ins
* ship in `library.ts`; users can add their own, scoped to the project. */
* ship in `library.ts`; users can add their own private ones via the
* shared catalog (see state/catalogStore.ts), not scoped to any one diagram. */
export interface DeviceCategoryDef {
id: string
name: string
@@ -134,7 +135,7 @@ export interface Connection {
notes?: string
}
export interface Project {
export interface Diagram {
id: string
name: string
createdAt: string
+1 -1
View File
@@ -10,7 +10,7 @@ const repository: CatalogRepository = new SupabaseCatalogRepository()
// Which public device templates you've hidden from your own palette — a
// per-user UI preference, not catalog data, so it's not a Supabase row: hiding
// a public entry only affects your view of it (see domain/project.ts's
// a public entry only affects your view of it (see domain/diagram.ts's
// allDeviceTemplates/hiddenDeviceTemplates), never the shared entry itself.
const HIDDEN_PUBLIC_TEMPLATES_KEY = 'av-planner:hiddenPublicDeviceTemplateIds'
function loadHiddenPublicIds(): Set<string> {
+9 -9
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand'
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from '../data/DiagramSnapshotRepository'
import { SupabaseDiagramSnapshotRepository } from '../data/SupabaseDiagramSnapshotRepository'
import type { Project } from '../domain/types'
import { useProjectStore } from './projectStore'
import type { Diagram } from '../domain/types'
import { useDiagramStore } from './diagramStore'
const repository: DiagramSnapshotRepository = new SupabaseDiagramSnapshotRepository()
@@ -12,12 +12,12 @@ interface DiagramSnapshotStoreState {
load: (diagramId: string) => Promise<void>
/** Restores a snapshot and applies it to the open diagram (via
* projectStore.applyRestoredProject) in one step. Skips writing a
* diagramStore.applyRestoredDiagram) in one step. Skips writing a
* pointless duplicate safety snapshot when the live diagram is already
* an unmodified copy of a snapshot still sitting in the list — see
* projectStore's restoredFromSnapshotId/restoredProjectUpdatedAt.
* Returns the restored Project, or null if the restore failed. */
restore: (diagramId: string, snapshotId: string) => Promise<Project | null>
* diagramStore's restoredFromSnapshotId/restoredDiagramUpdatedAt.
* Returns the restored Diagram, or null if the restore failed. */
restore: (diagramId: string, snapshotId: string) => Promise<Diagram | null>
}
export const useDiagramSnapshotStore = create<DiagramSnapshotStoreState>((set) => ({
@@ -30,13 +30,13 @@ export const useDiagramSnapshotStore = create<DiagramSnapshotStoreState>((set) =
},
restore: async (diagramId, snapshotId) => {
const { project, restoredFromSnapshotId, restoredProjectUpdatedAt } = useProjectStore.getState()
const { diagram, restoredFromSnapshotId, restoredDiagramUpdatedAt } = useDiagramStore.getState()
const knownUnmodifiedSinceLastRestore =
restoredFromSnapshotId !== null && project.updatedAt === restoredProjectUpdatedAt
restoredFromSnapshotId !== null && diagram.updatedAt === restoredDiagramUpdatedAt
const restored = await repository.restore(diagramId, snapshotId, !knownUnmodifiedSinceLastRestore)
if (restored) {
useProjectStore.getState().applyRestoredProject(restored, snapshotId)
useDiagramStore.getState().applyRestoredDiagram(restored, snapshotId)
const snapshots = await repository.list(diagramId)
set({ snapshots })
}
@@ -1,8 +1,8 @@
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 { Catalog, Connection, Device, DeviceCategory, Port, Project } from '../domain/types'
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'
@@ -34,14 +34,14 @@ function setLastDiagramId(id: string): void {
}
}
async function loadMostRecent(): Promise<Project | null> {
async function loadMostRecent(): Promise<Diagram | null> {
const list = await repository.list()
if (list.length === 0) return null
return repository.loadById(list[0].id)
}
interface ProjectStoreState {
project: Project
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
@@ -57,8 +57,8 @@ interface ProjectStoreState {
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 applyRestoredProject. `restoredProjectUpdatedAt` is the
* project's own `updatedAt` at that moment; touch() always advances
* 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
@@ -67,7 +67,7 @@ interface ProjectStoreState {
* snapshot when the user restores several old versions in a row without
* editing in between. */
restoredFromSnapshotId: string | null
restoredProjectUpdatedAt: string | null
restoredDiagramUpdatedAt: string | null
selectedDeviceId: string | null
selectedConnectionId: string | null
lastConnectionError: string | null
@@ -79,17 +79,17 @@ interface ProjectStoreState {
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. */
newProject: (name?: string) => Promise<void>
renameProject: (name: string) => void
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. */
importProject: (project: Project) => Promise<void>
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". */
applyRestoredProject: (project: Project, snapshotId: string) => void
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
@@ -132,17 +132,17 @@ interface ProjectStoreState {
removeConnection: (id: string) => void
}
function touch(project: Project): Project {
return { ...project, updatedAt: new Date().toISOString() }
function touch(diagram: Diagram): Diagram {
return { ...diagram, updatedAt: new Date().toISOString() }
}
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
project: createEmptyProject(),
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
diagram: createEmptyDiagram(),
isLoaded: false,
diagramList: [],
access: null,
restoredFromSnapshotId: null,
restoredProjectUpdatedAt: null,
restoredDiagramUpdatedAt: null,
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
@@ -151,10 +151,10 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
loadInitialDiagram: async () => {
const lastId = getLastDiagramId()
const loaded = lastId ? await repository.loadById(lastId) : null
const project = loaded ?? (await loadMostRecent()) ?? createEmptyProject()
setLastDiagramId(project.id)
const access = await repository.getAccess(project.id)
set({ project, access, isLoaded: true, restoredFromSnapshotId: null, restoredProjectUpdatedAt: 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()
},
@@ -163,45 +163,45 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
set({ diagramList })
},
newProject: async (name = 'Untitled Project') => {
const project = createEmptyProject(name)
setLastDiagramId(project.id)
newDiagram: async (name = 'Untitled Diagram') => {
const diagram = createEmptyDiagram(name)
setLastDiagramId(diagram.id)
set({
project,
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredProjectUpdatedAt: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(project)
const access = await repository.getAccess(project.id)
await repository.save(diagram)
const access = await repository.getAccess(diagram.id)
set({ access })
get().refreshDiagramList()
},
renameProject: (name) => {
set((state) => ({ project: touch({ ...state.project, name }) }))
renameDiagram: (name) => {
set((state) => ({ diagram: touch({ ...state.diagram, name }) }))
},
applyRestoredProject: (project, snapshotId) => {
applyRestoredDiagram: (diagram, snapshotId) => {
set({
project,
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: snapshotId,
restoredProjectUpdatedAt: project.updatedAt,
restoredDiagramUpdatedAt: diagram.updatedAt,
})
},
importProject: async (project) => {
const fresh = touch({ ...project, id: uuid() })
importDiagram: async (diagram) => {
const fresh = touch({ ...diagram, id: uuid() })
setLastDiagramId(fresh.id)
set({
project: fresh,
diagram: fresh,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredProjectUpdatedAt: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(fresh)
const access = await repository.getAccess(fresh.id)
@@ -210,34 +210,34 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
},
switchToDiagram: async (id) => {
if (id === get().project.id) return
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({
project: loaded,
diagram: loaded,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredProjectUpdatedAt: null,
restoredDiagramUpdatedAt: null,
})
},
deleteDiagram: async (id) => {
await repository.deleteById(id)
if (get().project.id === id) {
const next = (await loadMostRecent()) ?? createEmptyProject()
if (get().diagram.id === id) {
const next = (await loadMostRecent()) ?? createEmptyDiagram()
setLastDiagramId(next.id)
const access = await repository.getAccess(next.id)
set({
project: next,
diagram: next,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredProjectUpdatedAt: null,
restoredDiagramUpdatedAt: null,
})
}
get().refreshDiagramList()
@@ -247,37 +247,37 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
addDeviceFromTemplate: (catalog, templateId, position) => {
const { project } = get()
const { diagram } = get()
const template = allDeviceTemplates(catalog).find((t) => t.id === templateId)
if (!template) return
const device = createDeviceFromTemplate(template, position)
set({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id })
set({ diagram: touch({ ...diagram, devices: [...diagram.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)),
diagram: touch({
...state.diagram,
devices: state.diagram.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)),
diagram: touch({
...state.diagram,
devices: state.diagram.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(
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,
),
}),
@@ -288,9 +288,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
addPort: (deviceId, port) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
),
}),
@@ -300,9 +300,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
updatePort: (deviceId, portId, patch) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
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,
@@ -314,9 +314,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
clonePort: (deviceId, portId) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => {
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
@@ -331,12 +331,12 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
removePort: (deviceId, portId) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
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.project.connections.filter(
connections: state.diagram.connections.filter(
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
),
}),
@@ -344,8 +344,8 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
},
addConnection: (catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
const { project } = get()
const validation = validateConnection(project, catalog, {
const { diagram } = get()
const validation = validateConnection(diagram, catalog, {
sourceDeviceId,
sourcePortId,
targetDeviceId,
@@ -364,7 +364,7 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
cableTypeId,
}
set({
project: touch({ ...project, connections: [...project.connections, connection] }),
diagram: touch({ ...diagram, connections: [...diagram.connections, connection] }),
lastConnectionError: null,
selectedConnectionId: connection.id,
selectedDeviceId: null,
@@ -374,18 +374,18 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
updateConnection: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
connections: state.project.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
diagram: touch({
...state.diagram,
connections: state.diagram.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),
diagram: touch({
...state.diagram,
connections: state.diagram.connections.filter((c) => c.id !== id),
}),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
@@ -393,22 +393,22 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
}))
// Debounced autosave: fires whenever `project` changes (skipped until the
// Debounced autosave: fires whenever `diagram` changes (skipped until the
// initial load completes, so we don't immediately overwrite storage with a
// blank project on first mount).
// blank diagram on first mount).
let saveTimeout: ReturnType<typeof setTimeout> | undefined
useProjectStore.subscribe((state, prevState) => {
useDiagramStore.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.
// load, not a user edit) and any tick where `diagram` didn't change.
if (!prevState.isLoaded) return
if (state.project === prevState.project) 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.project).catch((err) => console.error('Autosave failed', err))
repository.save(state.diagram).catch((err) => console.error('Autosave failed', err))
}, 400)
})
@@ -26,7 +26,7 @@ create policy "catalog_submissions_insert" on public.catalog_submissions for ins
-- ----------------------------------------------------------------------
-- Usage-impact aggregate function.
--
-- A diagram's `data` JSONB mirrors the exported Project shape (see
-- A diagram's `data` JSONB mirrors the exported Diagram shape (see
-- data/exportImport.ts / domain/types.ts): devices[].templateId,
-- devices[].category, devices[].ports[].portTypeId, and
-- connections[].cableTypeId are the four places a catalog entity id can be