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