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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user