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