Wire the app to the shared Supabase catalog (public catalog read integration)
Moves port/cable/category/device-template data from per-diagram embedded storage to the global public/private catalog backed by Supabase, per organized-ideas.md's "live reference, not snapshot" decision. - domain/types.ts, project.ts, compatibility.ts, bom.ts: lookup functions now take an explicit Catalog parameter instead of deriving data from Project — Project is reduced to just diagram-scoped fields. - New CatalogRepository/SupabaseCatalogRepository (mirrors the DiagramRepository pattern) and catalogStore.ts, replacing the catalog-related actions that used to live in projectStore. - device_templates gets a plain-text manufacturer column for now (the normalized manufacturer catalog from organized-ideas.md §3 is its own future pass, not blocking this one). - domain/library.ts is no longer imported by the app — it's now only the source scripts/generate-seed.mjs reads to produce supabase/seed.sql. - Swept every UI call site via tsc -b until clean; oxlint clean; 23/23 pgTAP RLS tests still passing after a `supabase db reset`.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { computeBom } from '../../domain/bom'
|
import { computeBom } from '../../domain/bom'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
|
||||||
function formatCost(value: number): string {
|
function formatCost(value: number): string {
|
||||||
@@ -8,7 +9,8 @@ function formatCost(value: number): string {
|
|||||||
|
|
||||||
export default function BomPanel() {
|
export default function BomPanel() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const bom = useMemo(() => computeBom(project), [project])
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
const bom = useMemo(() => computeBom(project, catalog), [project, catalog])
|
||||||
|
|
||||||
const hasMissingCost =
|
const hasMissingCost =
|
||||||
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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/project'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
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'
|
||||||
@@ -38,9 +39,10 @@ function FlowCanvasInner() {
|
|||||||
const selectDevice = useProjectStore((s) => s.selectDevice)
|
const selectDevice = useProjectStore((s) => s.selectDevice)
|
||||||
const selectConnection = useProjectStore((s) => s.selectConnection)
|
const selectConnection = useProjectStore((s) => s.selectConnection)
|
||||||
const { screenToFlowPosition } = useReactFlow()
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
|
||||||
const portTypes = useMemo(() => allPortTypes(project), [project])
|
const portTypes = useMemo(() => allPortTypes(catalog), [catalog])
|
||||||
const cableTypes = useMemo(() => allCableTypes(project), [project])
|
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
|
||||||
|
|
||||||
const nodes: DeviceFlowNode[] = useMemo(
|
const nodes: DeviceFlowNode[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -58,10 +60,10 @@ function FlowCanvasInner() {
|
|||||||
type: 'device',
|
type: 'device',
|
||||||
position: device.position,
|
position: device.position,
|
||||||
selected: device.id === selectedDeviceId,
|
selected: device.id === selectedDeviceId,
|
||||||
data: { device, categoryName: deviceCategoryName(project, device.category), portInfos },
|
data: { device, categoryName: deviceCategoryName(catalog, device.category), portInfos },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[project, portTypes, selectedDeviceId],
|
[project, catalog, portTypes, selectedDeviceId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edges: CableFlowEdge[] = useMemo(
|
const edges: CableFlowEdge[] = useMemo(
|
||||||
@@ -92,14 +94,14 @@ function FlowCanvasInner() {
|
|||||||
const sourcePortId = parsePortId(c.sourceHandle)
|
const sourcePortId = parsePortId(c.sourceHandle)
|
||||||
const targetPortId = parsePortId(c.targetHandle)
|
const targetPortId = parsePortId(c.targetHandle)
|
||||||
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false
|
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false
|
||||||
return validateConnection(project, {
|
return validateConnection(project, catalog, {
|
||||||
sourceDeviceId: c.source,
|
sourceDeviceId: c.source,
|
||||||
sourcePortId,
|
sourcePortId,
|
||||||
targetDeviceId: c.target,
|
targetDeviceId: c.target,
|
||||||
targetPortId,
|
targetPortId,
|
||||||
}).valid
|
}).valid
|
||||||
},
|
},
|
||||||
[project],
|
[project, catalog],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
@@ -111,12 +113,12 @@ function FlowCanvasInner() {
|
|||||||
const targetDevice = project.devices.find((d) => d.id === connection.target)
|
const targetDevice = project.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(project, sourcePort.portTypeId)
|
const sourcePortType = sourcePort && getPortType(catalog, sourcePort.portTypeId)
|
||||||
const targetPortType = targetPort && getPortType(project, targetPort.portTypeId)
|
const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
|
||||||
const defaultCableType =
|
const defaultCableType =
|
||||||
sourcePortType && targetPortType && cableTypesForConnection(project, sourcePortType, targetPortType)[0]
|
sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0]
|
||||||
if (!defaultCableType) return
|
if (!defaultCableType) return
|
||||||
addConnection({
|
addConnection(catalog, {
|
||||||
sourceDeviceId: connection.source,
|
sourceDeviceId: connection.source,
|
||||||
sourcePortId,
|
sourcePortId,
|
||||||
targetDeviceId: connection.target,
|
targetDeviceId: connection.target,
|
||||||
@@ -124,7 +126,7 @@ function FlowCanvasInner() {
|
|||||||
cableTypeId: defaultCableType.id,
|
cableTypeId: defaultCableType.id,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[project, addConnection],
|
[project, catalog, addConnection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
@@ -161,9 +163,9 @@ function FlowCanvasInner() {
|
|||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
||||||
addDeviceFromTemplate(templateId, position)
|
addDeviceFromTemplate(catalog, templateId, position)
|
||||||
},
|
},
|
||||||
[screenToFlowPosition, addDeviceFromTemplate],
|
[screenToFlowPosition, catalog, addDeviceFromTemplate],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { allDeviceCategories } from '../../domain/project'
|
import { allDeviceCategories } from '../../domain/project'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
|
||||||
const NEW_CATEGORY_VALUE = '__new__'
|
const NEW_CATEGORY_VALUE = '__new__'
|
||||||
|
|
||||||
@@ -16,20 +16,20 @@ export default function CategorySelect({
|
|||||||
onChange: (categoryId: string) => void
|
onChange: (categoryId: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const project = useProjectStore((s) => s.project)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const addCustomDeviceCategory = useProjectStore((s) => s.addCustomDeviceCategory)
|
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [draftName, setDraftName] = useState('')
|
const [draftName, setDraftName] = useState('')
|
||||||
|
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => [...allDeviceCategories(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allDeviceCategories(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
|
|
||||||
const commitNewCategory = () => {
|
const commitNewCategory = async () => {
|
||||||
const trimmed = draftName.trim()
|
const trimmed = draftName.trim()
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
onChange(addCustomDeviceCategory(trimmed))
|
onChange(await addCustomDeviceCategory(trimmed))
|
||||||
}
|
}
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
setDraftName('')
|
setDraftName('')
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { cableTypesForConnection, getPortType } from '../../domain/project'
|
import { cableTypesForConnection, getPortType } from '../../domain/project'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
|
||||||
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const updateConnection = useProjectStore((s) => s.updateConnection)
|
const updateConnection = useProjectStore((s) => s.updateConnection)
|
||||||
const removeConnection = useProjectStore((s) => s.removeConnection)
|
const removeConnection = useProjectStore((s) => s.removeConnection)
|
||||||
|
|
||||||
@@ -15,10 +17,10 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
|
|
||||||
const availableCableTypes = useMemo(() => {
|
const availableCableTypes = useMemo(() => {
|
||||||
if (!sourcePort || !targetPort) return []
|
if (!sourcePort || !targetPort) return []
|
||||||
const sourcePortType = getPortType(project, sourcePort.portTypeId)
|
const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
|
||||||
const targetPortType = getPortType(project, targetPort.portTypeId)
|
const targetPortType = getPortType(catalog, targetPort.portTypeId)
|
||||||
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
|
return sourcePortType && targetPortType ? cableTypesForConnection(catalog, sourcePortType, targetPortType) : []
|
||||||
}, [project, sourcePort, targetPort])
|
}, [catalog, sourcePort, targetPort])
|
||||||
|
|
||||||
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
|
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ 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/project'
|
||||||
import type { PortDirection } from '../../domain/types'
|
import type { PortDirection } from '../../domain/types'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
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 project = useProjectStore((s) => s.project)
|
||||||
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const updateDevice = useProjectStore((s) => s.updateDevice)
|
const updateDevice = useProjectStore((s) => s.updateDevice)
|
||||||
const removeDevice = useProjectStore((s) => s.removeDevice)
|
const removeDevice = useProjectStore((s) => s.removeDevice)
|
||||||
const addPort = useProjectStore((s) => s.addPort)
|
const addPort = useProjectStore((s) => s.addPort)
|
||||||
@@ -17,8 +19,8 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|||||||
|
|
||||||
const device = project.devices.find((d) => d.id === deviceId)
|
const device = project.devices.find((d) => d.id === deviceId)
|
||||||
const portTypes = useMemo(
|
const portTypes = useMemo(
|
||||||
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!device) return null
|
if (!device) return null
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
import FlowCanvas from '../canvas/FlowCanvas'
|
import FlowCanvas from '../canvas/FlowCanvas'
|
||||||
import DevicePalette from '../palette/DevicePalette'
|
import DevicePalette from '../palette/DevicePalette'
|
||||||
@@ -9,12 +10,15 @@ import TopBar from './TopBar'
|
|||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const isLoaded = useProjectStore((s) => s.isLoaded)
|
const isLoaded = useProjectStore((s) => s.isLoaded)
|
||||||
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
|
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
|
||||||
|
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
||||||
|
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialDiagram()
|
loadInitialDiagram()
|
||||||
}, [loadInitialDiagram])
|
loadCatalog()
|
||||||
|
}, [loadInitialDiagram, loadCatalog])
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded || !isCatalogLoaded) {
|
||||||
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
|
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { allCableTypes, allPortTypes } from '../../domain/project'
|
import { allCableTypes, allPortTypes } from '../../domain/project'
|
||||||
import type { CableType, PortType } from '../../domain/types'
|
import type { CableType, PortType } from '../../domain/types'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
|
||||||
const UNITS: CableType['unit'][] = ['ft', 'm']
|
const UNITS: CableType['unit'][] = ['ft', 'm']
|
||||||
|
|
||||||
@@ -129,19 +129,19 @@ function CableTypeForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CableTypesPanel() {
|
export default function CableTypesPanel() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const addCustomCableType = useProjectStore((s) => s.addCustomCableType)
|
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
|
||||||
const updateCustomCableType = useProjectStore((s) => s.updateCustomCableType)
|
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const portTypes = useMemo(
|
const portTypes = useMemo(
|
||||||
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
const allTypes = useMemo(
|
const allTypes = useMemo(
|
||||||
() => [...allCableTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
|
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
|
||||||
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
|
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { allDeviceCategories, allDeviceTemplates, hiddenBuiltInDeviceTemplates } from '../../domain/project'
|
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project'
|
||||||
import type { DeviceTemplate } from '../../domain/types'
|
import type { DeviceTemplate } from '../../domain/types'
|
||||||
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
||||||
import Chevron from '../common/Chevron'
|
import Chevron from '../common/Chevron'
|
||||||
@@ -10,9 +11,11 @@ import DeviceTemplateEditor from './DeviceTemplateEditor'
|
|||||||
export default function DevicePalette() {
|
export default function DevicePalette() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
||||||
const removeCustomDeviceTemplate = useProjectStore((s) => s.removeCustomDeviceTemplate)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const hideBuiltInDeviceTemplate = useProjectStore((s) => s.hideBuiltInDeviceTemplate)
|
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
||||||
const restoreBuiltInDeviceTemplate = useProjectStore((s) => s.restoreBuiltInDeviceTemplate)
|
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
|
||||||
|
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
|
||||||
|
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
|
||||||
const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null)
|
const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null)
|
||||||
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
||||||
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
||||||
@@ -21,10 +24,10 @@ export default function DevicePalette() {
|
|||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const categories = allDeviceCategories(project)
|
const categories = allDeviceCategories(catalog)
|
||||||
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
|
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
|
||||||
const byCategory = new Map<string, DeviceTemplate[]>()
|
const byCategory = new Map<string, DeviceTemplate[]>()
|
||||||
for (const template of allDeviceTemplates(project)) {
|
for (const template of allDeviceTemplates(catalog, hiddenPublicIds)) {
|
||||||
const list = byCategory.get(template.category) ?? []
|
const list = byCategory.get(template.category) ?? []
|
||||||
list.push(template)
|
list.push(template)
|
||||||
byCategory.set(template.category, list)
|
byCategory.set(template.category, list)
|
||||||
@@ -36,7 +39,7 @@ export default function DevicePalette() {
|
|||||||
templates: templates.sort((a, b) => a.name.localeCompare(b.name)),
|
templates: templates.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
|
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
|
||||||
}, [project])
|
}, [catalog, hiddenPublicIds])
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
setExpandedCategories((prev) => {
|
setExpandedCategories((prev) => {
|
||||||
@@ -59,15 +62,15 @@ export default function DevicePalette() {
|
|||||||
const handleQuickAdd = (template: DeviceTemplate) => {
|
const handleQuickAdd = (template: DeviceTemplate) => {
|
||||||
// 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 = (project.devices.length % 8) * 24
|
||||||
addDeviceFromTemplate(template.id, { x: 80 + offset, y: 80 + offset })
|
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (template: DeviceTemplate) => {
|
const handleDelete = (template: DeviceTemplate) => {
|
||||||
if (template.builtIn) {
|
if (!template.custom) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`Hide "${template.name}" from your device library? It's a built-in default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`,
|
`Hide "${template.name}" from your device library? It's a public default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`,
|
||||||
)
|
)
|
||||||
if (confirmed) hideBuiltInDeviceTemplate(template.id)
|
if (confirmed) hidePublicDeviceTemplate(template.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
@@ -76,7 +79,7 @@ export default function DevicePalette() {
|
|||||||
if (confirmed) removeCustomDeviceTemplate(template.id)
|
if (confirmed) removeCustomDeviceTemplate(template.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiddenDefaults = useMemo(() => hiddenBuiltInDeviceTemplates(project), [project])
|
const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
|
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
|
||||||
@@ -129,11 +132,11 @@ export default function DevicePalette() {
|
|||||||
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
||||||
<div className="truncate text-[10px] text-slate-400">
|
<div className="truncate text-[10px] text-slate-400">
|
||||||
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
|
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
|
||||||
{!template.builtIn ? ' · custom' : ''}
|
{template.custom ? ' · custom' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||||
{!template.builtIn && (
|
{template.custom && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorTarget(template)}
|
onClick={() => setEditorTarget(template)}
|
||||||
title="Edit this device"
|
title="Edit this device"
|
||||||
@@ -144,7 +147,7 @@ export default function DevicePalette() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(template)}
|
onClick={() => handleDelete(template)}
|
||||||
title={template.builtIn ? 'Hide this default device' : 'Delete from library'}
|
title={template.custom ? 'Delete from library' : 'Hide this default device'}
|
||||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -183,7 +186,7 @@ export default function DevicePalette() {
|
|||||||
<li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs">
|
<li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs">
|
||||||
<span className="truncate text-slate-600">{template.name}</span>
|
<span className="truncate text-slate-600">{template.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => restoreBuiltInDeviceTemplate(template.id)}
|
onClick={() => restorePublicDeviceTemplate(template.id)}
|
||||||
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
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/project'
|
||||||
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import CategorySelect from '../common/CategorySelect'
|
import CategorySelect from '../common/CategorySelect'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
@@ -31,14 +31,14 @@ export default function DeviceTemplateEditor({
|
|||||||
* palette can expand that category and reveal what was just created/edited. */
|
* palette can expand that category and reveal what was just created/edited. */
|
||||||
onSaved?: (categoryId: string) => void
|
onSaved?: (categoryId: string) => void
|
||||||
}) {
|
}) {
|
||||||
const project = useProjectStore((s) => s.project)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const addCustomDeviceTemplate = useProjectStore((s) => s.addCustomDeviceTemplate)
|
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
|
||||||
const updateCustomDeviceTemplate = useProjectStore((s) => s.updateCustomDeviceTemplate)
|
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
|
||||||
const addCustomPortType = useProjectStore((s) => s.addCustomPortType)
|
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
||||||
|
|
||||||
const portTypes = useMemo(
|
const portTypes = useMemo(
|
||||||
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [name, setName] = useState(template?.name ?? '')
|
const [name, setName] = useState(template?.name ?? '')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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/project'
|
||||||
import type { PortType } from '../../domain/types'
|
import type { PortType } from '../../domain/types'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
|
|
||||||
function EditCustomPortTypeForm({
|
function EditCustomPortTypeForm({
|
||||||
portType,
|
portType,
|
||||||
@@ -13,7 +13,7 @@ function EditCustomPortTypeForm({
|
|||||||
allTypes: PortType[]
|
allTypes: PortType[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
const updateCustomPortType = useProjectStore((s) => s.updateCustomPortType)
|
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
|
||||||
const [name, setName] = useState(portType.name)
|
const [name, setName] = useState(portType.name)
|
||||||
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
|
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
|
||||||
const [family, setFamily] = useState(portType.family)
|
const [family, setFamily] = useState(portType.family)
|
||||||
@@ -100,12 +100,12 @@ function EditCustomPortTypeForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PortTypesPanel() {
|
export default function PortTypesPanel() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const allTypes = useMemo(
|
const allTypes = useMemo(
|
||||||
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
[project],
|
[catalog],
|
||||||
)
|
)
|
||||||
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
|
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
|
||||||
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
|
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../domain/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage abstraction for the global port/cable/category/device-template
|
||||||
|
* catalog — mirrors DiagramRepository's role, but for catalog data instead
|
||||||
|
* of diagrams. Fetching returns everything the current user can see (public
|
||||||
|
* entries plus their own private ones); RLS does that filtering server-side,
|
||||||
|
* so implementations don't need to filter client-side.
|
||||||
|
*
|
||||||
|
* Only covers the "read + manage your own private entries" surface for now.
|
||||||
|
* Submitting a private entry for public review/promotion is a separate,
|
||||||
|
* not-yet-built workflow (organized-ideas.md §3/§9) — these `add*`/`update*`
|
||||||
|
* methods always create or edit is_public = false rows you own.
|
||||||
|
*/
|
||||||
|
export interface CatalogRepository {
|
||||||
|
/** Everything visible to the current user: public entries plus their own private ones. */
|
||||||
|
load(): Promise<Catalog>
|
||||||
|
|
||||||
|
addDeviceCategory(name: string): Promise<DeviceCategoryDef>
|
||||||
|
|
||||||
|
addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
|
||||||
|
updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
|
||||||
|
|
||||||
|
addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
|
||||||
|
updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
|
||||||
|
|
||||||
|
addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
|
||||||
|
updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
|
||||||
|
removeDeviceTemplate(id: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
|
||||||
|
import type { CatalogRepository } from './CatalogRepository'
|
||||||
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
|
interface DeviceCategoryRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortTypeRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: PortType['category']
|
||||||
|
family: string
|
||||||
|
compatible_family_ids: string[]
|
||||||
|
max_connections: number | null
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CableTypeRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
family2: string | null
|
||||||
|
unit: CableType['unit']
|
||||||
|
cost_per_unit: number | null
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceTemplateRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category_id: string
|
||||||
|
manufacturer: string | null
|
||||||
|
model: string | null
|
||||||
|
cost: number | null
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceTemplatePortRow {
|
||||||
|
id: string
|
||||||
|
device_template_id: string
|
||||||
|
name: string
|
||||||
|
direction: Port['direction']
|
||||||
|
port_type_id: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
|
||||||
|
return { id: row.id, name: row.name, custom: !row.is_public }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPortType(row: PortTypeRow): PortType {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category,
|
||||||
|
family: row.family,
|
||||||
|
compatibleFamilyIds: row.compatible_family_ids.length > 0 ? row.compatible_family_ids : undefined,
|
||||||
|
maxConnections: row.max_connections ?? undefined,
|
||||||
|
custom: !row.is_public,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCableType(row: CableTypeRow): CableType {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
family: row.family,
|
||||||
|
family2: row.family2 ?? undefined,
|
||||||
|
unit: row.unit,
|
||||||
|
costPerUnit: row.cost_per_unit ?? undefined,
|
||||||
|
custom: !row.is_public,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRow[]): DeviceTemplate {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category_id,
|
||||||
|
manufacturer: row.manufacturer ?? undefined,
|
||||||
|
model: row.model ?? undefined,
|
||||||
|
cost: row.cost ?? undefined,
|
||||||
|
ports: portRows
|
||||||
|
.filter((p) => p.device_template_id === row.id)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id })),
|
||||||
|
custom: !row.is_public,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the app with the shared Supabase catalog tables instead of the
|
||||||
|
* per-diagram embedded custom-type arrays the app used before this pass.
|
||||||
|
* RLS scopes every select to what the current user can see (all public
|
||||||
|
* entries, plus their own private ones) — see the `*_select` policies in
|
||||||
|
* the init schema migration. Every write here creates or edits a private
|
||||||
|
* (is_public = false) row owned by the current user; there is no public
|
||||||
|
* write path yet (that's the submission workflow, organized-ideas.md §3).
|
||||||
|
*/
|
||||||
|
export class SupabaseCatalogRepository implements CatalogRepository {
|
||||||
|
async load(): Promise<Catalog> {
|
||||||
|
const [categories, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
|
||||||
|
supabase.from('device_categories').select('id, name, is_public').order('name'),
|
||||||
|
supabase
|
||||||
|
.from('port_types')
|
||||||
|
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
|
||||||
|
.order('name'),
|
||||||
|
supabase
|
||||||
|
.from('cable_types')
|
||||||
|
.select('id, name, family, family2, unit, cost_per_unit, is_public')
|
||||||
|
.order('name'),
|
||||||
|
supabase.from('device_templates').select('id, name, category_id, manufacturer, model, cost, is_public').order('name'),
|
||||||
|
supabase.from('device_template_ports').select('id, device_template_id, name, direction, port_type_id, sort_order'),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const [label, result] of [
|
||||||
|
['device categories', categories],
|
||||||
|
['port types', portTypes],
|
||||||
|
['cable types', cableTypes],
|
||||||
|
['device templates', templates],
|
||||||
|
['device template ports', templatePorts],
|
||||||
|
] as const) {
|
||||||
|
if (result.error) console.error(`Failed to load ${label} from Supabase`, result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
|
||||||
|
return {
|
||||||
|
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
|
||||||
|
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
|
||||||
|
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
|
||||||
|
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentUserId(): Promise<string | null> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
return user?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
|
||||||
|
const ownerId = await this.currentUserId()
|
||||||
|
const id = uuid()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('device_categories')
|
||||||
|
.insert({ id, name, is_public: false, owner_id: ownerId })
|
||||||
|
if (error) console.error('Failed to add device category to Supabase', error)
|
||||||
|
return { id, name, custom: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
|
||||||
|
const ownerId = await this.currentUserId()
|
||||||
|
const id = uuid()
|
||||||
|
const { error } = await supabase.from('port_types').insert({
|
||||||
|
id,
|
||||||
|
name: portType.name,
|
||||||
|
category: portType.category,
|
||||||
|
family: portType.family,
|
||||||
|
compatible_family_ids: portType.compatibleFamilyIds ?? [],
|
||||||
|
max_connections: portType.maxConnections ?? null,
|
||||||
|
is_public: false,
|
||||||
|
owner_id: ownerId,
|
||||||
|
})
|
||||||
|
if (error) console.error('Failed to add port type to Supabase', error)
|
||||||
|
return { ...portType, id, custom: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
if (patch.name !== undefined) payload.name = patch.name
|
||||||
|
if (patch.category !== undefined) payload.category = patch.category
|
||||||
|
if (patch.family !== undefined) payload.family = patch.family
|
||||||
|
if (patch.compatibleFamilyIds !== undefined) payload.compatible_family_ids = patch.compatibleFamilyIds
|
||||||
|
if (patch.maxConnections !== undefined) payload.max_connections = patch.maxConnections
|
||||||
|
const { error } = await supabase.from('port_types').update(payload).eq('id', id)
|
||||||
|
if (error) console.error('Failed to update port type in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
|
||||||
|
const ownerId = await this.currentUserId()
|
||||||
|
const id = uuid()
|
||||||
|
const { error } = await supabase.from('cable_types').insert({
|
||||||
|
id,
|
||||||
|
name: cableType.name,
|
||||||
|
family: cableType.family,
|
||||||
|
family2: cableType.family2 ?? null,
|
||||||
|
unit: cableType.unit,
|
||||||
|
cost_per_unit: cableType.costPerUnit ?? null,
|
||||||
|
is_public: false,
|
||||||
|
owner_id: ownerId,
|
||||||
|
})
|
||||||
|
if (error) console.error('Failed to add cable type to Supabase', error)
|
||||||
|
return { ...cableType, id, custom: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
if (patch.name !== undefined) payload.name = patch.name
|
||||||
|
if (patch.family !== undefined) payload.family = patch.family
|
||||||
|
if (patch.family2 !== undefined) payload.family2 = patch.family2
|
||||||
|
if (patch.unit !== undefined) payload.unit = patch.unit
|
||||||
|
if (patch.costPerUnit !== undefined) payload.cost_per_unit = patch.costPerUnit
|
||||||
|
const { error } = await supabase.from('cable_types').update(payload).eq('id', id)
|
||||||
|
if (error) console.error('Failed to update cable type in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
|
||||||
|
const ownerId = await this.currentUserId()
|
||||||
|
const id = uuid()
|
||||||
|
const { error } = await supabase.from('device_templates').insert({
|
||||||
|
id,
|
||||||
|
name: template.name,
|
||||||
|
category_id: template.category,
|
||||||
|
manufacturer: template.manufacturer ?? null,
|
||||||
|
model: template.model ?? null,
|
||||||
|
cost: template.cost ?? null,
|
||||||
|
is_public: false,
|
||||||
|
owner_id: ownerId,
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to add device template to Supabase', error)
|
||||||
|
return { ...template, id, custom: true }
|
||||||
|
}
|
||||||
|
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
|
||||||
|
if (ports.length > 0) {
|
||||||
|
const { error: portsError } = await supabase.from('device_template_ports').insert(
|
||||||
|
ports.map((port) => ({
|
||||||
|
id: port.id,
|
||||||
|
device_template_id: id,
|
||||||
|
name: port.name,
|
||||||
|
direction: port.direction,
|
||||||
|
port_type_id: port.portTypeId,
|
||||||
|
sort_order: port.sortOrder,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
|
||||||
|
}
|
||||||
|
return { ...template, id, ports, custom: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
if (patch.name !== undefined) payload.name = patch.name
|
||||||
|
if (patch.category !== undefined) payload.category_id = patch.category
|
||||||
|
if (patch.manufacturer !== undefined) payload.manufacturer = patch.manufacturer
|
||||||
|
if (patch.model !== undefined) payload.model = patch.model
|
||||||
|
if (patch.cost !== undefined) payload.cost = patch.cost
|
||||||
|
if (Object.keys(payload).length > 0) {
|
||||||
|
const { error } = await supabase.from('device_templates').update(payload).eq('id', id)
|
||||||
|
if (error) console.error('Failed to update device template in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.ports !== undefined) {
|
||||||
|
// Simplest correct approach: replace the whole port set rather than
|
||||||
|
// diffing. Templates are edited as a whole in the UI (DeviceTemplateEditor),
|
||||||
|
// so there's no risk of clobbering a concurrent partial edit.
|
||||||
|
const { error: deleteError } = await supabase.from('device_template_ports').delete().eq('device_template_id', id)
|
||||||
|
if (deleteError) console.error('Failed to clear device template ports in Supabase', deleteError)
|
||||||
|
if (patch.ports.length > 0) {
|
||||||
|
const { error: insertError } = await supabase.from('device_template_ports').insert(
|
||||||
|
patch.ports.map((port, index) => ({
|
||||||
|
id: uuid(),
|
||||||
|
device_template_id: id,
|
||||||
|
name: port.name,
|
||||||
|
direction: port.direction,
|
||||||
|
port_type_id: port.portTypeId,
|
||||||
|
sort_order: index,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDeviceTemplate(id: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('device_templates').delete().eq('id', id)
|
||||||
|
if (error) console.error('Failed to remove device template from Supabase', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,18 +47,15 @@ export async function readProjectFile(file: File): Promise<Project> {
|
|||||||
return normalizeProject(parsed)
|
return normalizeProject(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills in collections added after a project may have been saved/exported,
|
/** Strips any legacy per-project catalog fields (customDeviceTemplates,
|
||||||
* so older data (from localStorage or an older export file) doesn't crash
|
* customPortTypes, etc.) that might still be present in an older export
|
||||||
* code that expects these arrays to always exist. */
|
* 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 {
|
export function normalizeProject(project: Project): Project {
|
||||||
return {
|
const { id, name, createdAt, updatedAt, devices, connections } = project
|
||||||
...project,
|
return { id, name, createdAt, updatedAt, devices, connections }
|
||||||
customDeviceTemplates: project.customDeviceTemplates ?? [],
|
|
||||||
customPortTypes: project.customPortTypes ?? [],
|
|
||||||
customCableTypes: project.customCableTypes ?? [],
|
|
||||||
customDeviceCategories: project.customDeviceCategories ?? [],
|
|
||||||
hiddenBuiltInDeviceTemplateIds: project.hiddenBuiltInDeviceTemplateIds ?? [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ProjectImportError }
|
export { ProjectImportError }
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { allCableTypes } from './project'
|
import { allCableTypes } from './project'
|
||||||
import type { CableType, Connection, Device, Project } from './types'
|
import type { CableType, Catalog, Connection, Device, Project } from './types'
|
||||||
|
|
||||||
export interface BomCableLine {
|
export interface BomCableLine {
|
||||||
cableTypeId: string
|
cableTypeId: string
|
||||||
@@ -66,8 +66,8 @@ function connectionCost(connection: Connection, cableType: CableType | undefined
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeBom(project: Project): Bom {
|
export function computeBom(project: Project, catalog: Catalog): Bom {
|
||||||
const cableTypes = new Map(allCableTypes(project).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>()
|
||||||
for (const connection of project.connections) {
|
for (const connection of project.connections) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
|
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
|
||||||
import type { Connection, Device, PortType, Project } from './types'
|
import type { Catalog, Connection, Device, PortType, Project } 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
|
||||||
@@ -37,6 +37,7 @@ export interface ConnectionValidation {
|
|||||||
*/
|
*/
|
||||||
export function validateConnection(
|
export function validateConnection(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
catalog: Catalog,
|
||||||
candidate: ConnectionCandidate,
|
candidate: ConnectionCandidate,
|
||||||
existingConnections: Connection[] = project.connections,
|
existingConnections: Connection[] = project.connections,
|
||||||
): ConnectionValidation {
|
): ConnectionValidation {
|
||||||
@@ -62,15 +63,15 @@ export function validateConnection(
|
|||||||
return { valid: false, reason: 'Cannot connect two output ports.' }
|
return { valid: false, reason: 'Cannot connect two output ports.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePortType = getPortType(project, sourcePort.portTypeId)
|
const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
|
||||||
const targetPortType = getPortType(project, targetPort.portTypeId)
|
const targetPortType = getPortType(catalog, targetPort.portTypeId)
|
||||||
if (!sourcePortType || !targetPortType) {
|
if (!sourcePortType || !targetPortType) {
|
||||||
return { valid: false, reason: 'Unknown port type.' }
|
return { valid: false, reason: 'Unknown port type.' }
|
||||||
}
|
}
|
||||||
// Directly compatible port types, or bridgeable by a specific adapter
|
// Directly compatible port types, or bridgeable by a specific adapter
|
||||||
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the
|
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the
|
||||||
// port types themselves declare no general compatibility.
|
// port types themselves declare no general compatibility.
|
||||||
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(project, sourcePortType, targetPortType)) {
|
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(catalog, sourcePortType, targetPortType)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
|
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
|
||||||
|
|||||||
+5
-12
@@ -1,9 +1,11 @@
|
|||||||
import type { CableType, DeviceCategoryDef, DeviceTemplate, PortType } from './types'
|
import type { CableType, DeviceCategoryDef, DeviceTemplate, PortType } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in connector/cable/device libraries. These ship read-only
|
* Built-in connector/cable/device libraries — the source of truth for the
|
||||||
* (`builtIn`/`custom: false`-ish via omission) and are merged with each
|
* public catalog's seed data. Not imported by the app itself anymore (the
|
||||||
* project's custom entries at lookup time — see `domain/project.ts`.
|
* catalog is fetched from Supabase at runtime, see state/catalogStore.ts);
|
||||||
|
* this file is read only by scripts/generate-seed.mjs, which turns it into
|
||||||
|
* supabase/seed.sql. Re-run that script after editing anything here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const BUILT_IN_DEVICE_CATEGORIES: DeviceCategoryDef[] = [
|
export const BUILT_IN_DEVICE_CATEGORIES: DeviceCategoryDef[] = [
|
||||||
@@ -162,7 +164,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-display',
|
id: 'dt-display',
|
||||||
name: 'Display (TV / Projector)',
|
name: 'Display (TV / Projector)',
|
||||||
category: 'display',
|
category: 'display',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||||
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||||
@@ -173,7 +174,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-media-player',
|
id: 'dt-media-player',
|
||||||
name: 'Media Player / Streamer',
|
name: 'Media Player / Streamer',
|
||||||
category: 'video-source',
|
category: 'video-source',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
||||||
{ id: 'p2', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
|
{ id: 'p2', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
|
||||||
@@ -183,7 +183,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-avr',
|
id: 'dt-avr',
|
||||||
name: 'AV Receiver',
|
name: 'AV Receiver',
|
||||||
category: 'amplifier',
|
category: 'amplifier',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||||
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||||
@@ -198,14 +197,12 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-speaker',
|
id: 'dt-speaker',
|
||||||
name: 'Speaker (Passive)',
|
name: 'Speaker (Passive)',
|
||||||
category: 'speaker',
|
category: 'speaker',
|
||||||
builtIn: true,
|
|
||||||
ports: [{ id: 'p1', name: 'Speaker In', direction: 'input', portTypeId: 'pt-speaker-wire' }],
|
ports: [{ id: 'p1', name: 'Speaker In', direction: 'input', portTypeId: 'pt-speaker-wire' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dt-switch',
|
id: 'dt-switch',
|
||||||
name: 'Network Switch',
|
name: 'Network Switch',
|
||||||
category: 'network',
|
category: 'network',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'Port 1', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
{ id: 'p1', name: 'Port 1', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||||
{ id: 'p2', name: 'Port 2', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
{ id: 'p2', name: 'Port 2', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||||
@@ -218,7 +215,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-camera',
|
id: 'dt-camera',
|
||||||
name: 'PTZ Camera',
|
name: 'PTZ Camera',
|
||||||
category: 'camera',
|
category: 'camera',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'SDI Out', direction: 'output', portTypeId: 'pt-sdi' },
|
{ id: 'p1', name: 'SDI Out', direction: 'output', portTypeId: 'pt-sdi' },
|
||||||
{ id: 'p2', name: 'Control (RJ45)', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
{ id: 'p2', name: 'Control (RJ45)', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||||
@@ -229,7 +225,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-computer',
|
id: 'dt-computer',
|
||||||
name: 'Computer / Laptop',
|
name: 'Computer / Laptop',
|
||||||
category: 'computer',
|
category: 'computer',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
||||||
{ id: 'p2', name: 'USB-C', direction: 'bidirectional', portTypeId: 'pt-usb-c' },
|
{ id: 'p2', name: 'USB-C', direction: 'bidirectional', portTypeId: 'pt-usb-c' },
|
||||||
@@ -245,7 +240,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-adapter-midi-trs-a-to-din',
|
id: 'dt-adapter-midi-trs-a-to-din',
|
||||||
name: '3.5mm-to-DIN MIDI Adapter (Type A)',
|
name: '3.5mm-to-DIN MIDI Adapter (Type A)',
|
||||||
category: 'adapter',
|
category: 'adapter',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-a' },
|
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-a' },
|
||||||
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
||||||
@@ -255,7 +249,6 @@ export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
|||||||
id: 'dt-adapter-midi-trs-b-to-din',
|
id: 'dt-adapter-midi-trs-b-to-din',
|
||||||
name: '3.5mm-to-DIN MIDI Adapter (Type B)',
|
name: '3.5mm-to-DIN MIDI Adapter (Type B)',
|
||||||
category: 'adapter',
|
category: 'adapter',
|
||||||
builtIn: true,
|
|
||||||
ports: [
|
ports: [
|
||||||
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-b' },
|
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-b' },
|
||||||
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
||||||
|
|||||||
+30
-63
@@ -1,58 +1,46 @@
|
|||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
import { BUILT_IN_CABLE_TYPES, BUILT_IN_DEVICE_CATEGORIES, BUILT_IN_DEVICE_TEMPLATES, BUILT_IN_PORT_TYPES } from './library'
|
import type { CableType, Catalog, Device, DeviceCategoryDef, DeviceTemplate, PortType, Project } from './types'
|
||||||
import type { CableType, Device, DeviceCategoryDef, DeviceTemplate, Port, PortType, Project } from './types'
|
|
||||||
|
|
||||||
/** Merges built-in + project-custom entries. Custom entries with a matching
|
export function allPortTypes(catalog: Catalog): PortType[] {
|
||||||
* id win, so a project could in principle override a built-in (not exposed
|
return catalog.portTypes
|
||||||
* in the UI today, but keeps the merge rule simple and predictable). */
|
|
||||||
function mergeById<T extends { id: string }>(builtIns: T[], custom: T[]): T[] {
|
|
||||||
const byId = new Map(builtIns.map((item) => [item.id, item]))
|
|
||||||
for (const item of custom) byId.set(item.id, item)
|
|
||||||
return [...byId.values()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allPortTypes(project: Project): PortType[] {
|
export function allCableTypes(catalog: Catalog): CableType[] {
|
||||||
return mergeById(BUILT_IN_PORT_TYPES, project.customPortTypes)
|
return catalog.cableTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allCableTypes(project: Project): CableType[] {
|
/** `hiddenPublicIds` is a per-user UI preference (see state/catalogStore.ts),
|
||||||
return mergeById(BUILT_IN_CABLE_TYPES, project.customCableTypes)
|
* not part of the catalog data itself — you can hide any public template
|
||||||
|
* from your own palette without affecting anyone else or the shared entry. */
|
||||||
|
export function allDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string> = new Set()): DeviceTemplate[] {
|
||||||
|
return catalog.deviceTemplates.filter((t) => t.custom || !hiddenPublicIds.has(t.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allDeviceTemplates(project: Project): DeviceTemplate[] {
|
/** Public templates the user has hidden, for a "restore" list. */
|
||||||
const merged = mergeById(BUILT_IN_DEVICE_TEMPLATES, project.customDeviceTemplates)
|
export function hiddenDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string>): DeviceTemplate[] {
|
||||||
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
|
return catalog.deviceTemplates.filter((t) => !t.custom && hiddenPublicIds.has(t.id))
|
||||||
return merged.filter((t) => !(t.builtIn && hidden.has(t.id)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Built-in templates the user has hidden, for a "restore" list — deleting a
|
export function allDeviceCategories(catalog: Catalog): DeviceCategoryDef[] {
|
||||||
* custom template is permanent, but hiding a built-in one isn't, since the
|
return catalog.deviceCategories
|
||||||
* built-in still exists in code and can always be shown again. */
|
|
||||||
export function hiddenBuiltInDeviceTemplates(project: Project): DeviceTemplate[] {
|
|
||||||
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
|
|
||||||
return BUILT_IN_DEVICE_TEMPLATES.filter((t) => hidden.has(t.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allDeviceCategories(project: Project): DeviceCategoryDef[] {
|
export function getDeviceCategory(catalog: Catalog, categoryId: string): DeviceCategoryDef | undefined {
|
||||||
return mergeById(BUILT_IN_DEVICE_CATEGORIES, project.customDeviceCategories)
|
return allDeviceCategories(catalog).find((c) => c.id === categoryId)
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeviceCategory(project: Project, categoryId: string): DeviceCategoryDef | undefined {
|
|
||||||
return allDeviceCategories(project).find((c) => c.id === categoryId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Friendly label for a category id, falling back to the raw id if it's
|
/** Friendly label for a category id, falling back to the raw id if it's
|
||||||
* somehow unknown (e.g. data from an older export) rather than crashing. */
|
* somehow unknown (e.g. data from an older export) rather than crashing. */
|
||||||
export function deviceCategoryName(project: Project, categoryId: string): string {
|
export function deviceCategoryName(catalog: Catalog, categoryId: string): string {
|
||||||
return getDeviceCategory(project, categoryId)?.name ?? categoryId
|
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPortType(project: Project, portTypeId: string): PortType | undefined {
|
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
|
||||||
return allPortTypes(project).find((pt) => pt.id === portTypeId)
|
return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCableType(project: Project, cableTypeId: string): CableType | undefined {
|
export function getCableType(catalog: Catalog, cableTypeId: string): CableType | undefined {
|
||||||
return allCableTypes(project).find((ct) => ct.id === cableTypeId)
|
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The set of connector families a port type will mate with: its own family
|
/** The set of connector families a port type will mate with: its own family
|
||||||
@@ -82,9 +70,9 @@ function cableFits(cableType: CableType, sourceFamilies: Set<string>, targetFami
|
|||||||
/** Cable types usable with the given port type at (at least) one end — for
|
/** Cable types usable with the given port type at (at least) one end — for
|
||||||
* a plain cable that means its family matches; for an adapter cable it's
|
* a plain cable that means its family matches; for an adapter cable it's
|
||||||
* enough that either end matches. */
|
* enough that either end matches. */
|
||||||
export function cableTypesForPortType(project: Project, portType: PortType): CableType[] {
|
export function cableTypesForPortType(catalog: Catalog, portType: PortType): CableType[] {
|
||||||
const families = acceptedFamilies(portType)
|
const families = acceptedFamilies(portType)
|
||||||
return allCableTypes(project).filter((ct) => {
|
return allCableTypes(catalog).filter((ct) => {
|
||||||
const [endA, endB] = cableEnds(ct)
|
const [endA, endB] = cableEnds(ct)
|
||||||
return families.has(endA) || families.has(endB)
|
return families.has(endA) || families.has(endB)
|
||||||
})
|
})
|
||||||
@@ -102,21 +90,17 @@ export function cableTypesForPortType(project: Project, portType: PortType): Cab
|
|||||||
* aren't declared generally compatible still be wired with the right cable,
|
* aren't declared generally compatible still be wired with the right cable,
|
||||||
* without changing either port type's own compatibility rules.
|
* without changing either port type's own compatibility rules.
|
||||||
*/
|
*/
|
||||||
export function cableTypesForConnection(
|
export function cableTypesForConnection(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): CableType[] {
|
||||||
project: Project,
|
|
||||||
sourcePortType: PortType,
|
|
||||||
targetPortType: PortType,
|
|
||||||
): CableType[] {
|
|
||||||
const sourceFamilies = acceptedFamilies(sourcePortType)
|
const sourceFamilies = acceptedFamilies(sourcePortType)
|
||||||
const targetFamilies = acceptedFamilies(targetPortType)
|
const targetFamilies = acceptedFamilies(targetPortType)
|
||||||
return allCableTypes(project).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies))
|
return allCableTypes(catalog).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether any cable type (built-in or custom) could bridge these two port
|
/** Whether any cable type (built-in or custom) could bridge these two port
|
||||||
* types — used to allow a connection even when the port types aren't
|
* types — used to allow a connection even when the port types aren't
|
||||||
* declared directly compatible, as long as a specific adapter cable exists. */
|
* declared directly compatible, as long as a specific adapter cable exists. */
|
||||||
export function hasBridgingCableType(project: Project, sourcePortType: PortType, targetPortType: PortType): boolean {
|
export function hasBridgingCableType(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): boolean {
|
||||||
return cableTypesForConnection(project, sourcePortType, targetPortType).length > 0
|
return cableTypesForConnection(catalog, sourcePortType, targetPortType).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,20 +134,12 @@ export function createEmptyProject(name = 'Untitled Project'): Project {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
devices: [],
|
devices: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
customDeviceTemplates: [],
|
|
||||||
customPortTypes: [],
|
|
||||||
customCableTypes: [],
|
|
||||||
customDeviceCategories: [],
|
|
||||||
hiddenBuiltInDeviceTemplateIds: [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Instantiates a device on the canvas from a template, deep-copying ports
|
/** Instantiates a device on the canvas from a template, deep-copying ports
|
||||||
* (with fresh ids) so editing the instance never mutates the template. */
|
* (with fresh ids) so editing the instance never mutates the template. */
|
||||||
export function createDeviceFromTemplate(
|
export function createDeviceFromTemplate(template: DeviceTemplate, position: { x: number; y: number }): Device {
|
||||||
template: DeviceTemplate,
|
|
||||||
position: { x: number; y: number },
|
|
||||||
): Device {
|
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: template.name,
|
name: template.name,
|
||||||
@@ -177,15 +153,6 @@ export function createDeviceFromTemplate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankPort(overrides: Partial<Port> = {}): Port {
|
|
||||||
return {
|
|
||||||
id: uuid(),
|
|
||||||
name: overrides.name ?? 'New Port',
|
|
||||||
direction: overrides.direction ?? 'output',
|
|
||||||
portTypeId: overrides.portTypeId ?? BUILT_IN_PORT_TYPES[0].id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2".
|
/** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2".
|
||||||
* Only increments a number that's the very last thing in the name (trailing
|
* Only increments a number that's the very last thing in the name (trailing
|
||||||
* whitespace aside), so labels like `1/4" TS In` aren't misread as ending
|
* whitespace aside), so labels like `1/4" TS In` aren't misread as ending
|
||||||
|
|||||||
+21
-10
@@ -66,7 +66,12 @@ export interface DeviceTemplate {
|
|||||||
/** Suggested default cost, copied onto each placed Device at creation time
|
/** Suggested default cost, copied onto each placed Device at creation time
|
||||||
* (same as ports) and independently editable per instance afterward. */
|
* (same as ports) and independently editable per instance afterward. */
|
||||||
cost?: number
|
cost?: number
|
||||||
builtIn?: boolean
|
/** True for your own private template (is_public = false, owner = you).
|
||||||
|
* Absent/false for anything public — whether an original seeded default
|
||||||
|
* or a user submission that's since been approved — since both behave
|
||||||
|
* the same way for you: not directly editable, only hideable from your
|
||||||
|
* own palette (see catalogStore's hidePublicDeviceTemplate). */
|
||||||
|
custom?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A device placed on the canvas. Ports are copied from the template at
|
/** A device placed on the canvas. Ports are copied from the template at
|
||||||
@@ -136,13 +141,19 @@ export interface Project {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
devices: Device[]
|
devices: Device[]
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
/** User-defined additions to the built-in libraries, scoped to this project. */
|
}
|
||||||
customDeviceTemplates: DeviceTemplate[]
|
|
||||||
customPortTypes: PortType[]
|
/**
|
||||||
customCableTypes: CableType[]
|
* The full set of port types / cable types / device categories / device
|
||||||
customDeviceCategories: DeviceCategoryDef[]
|
* templates visible to the current user — public entries plus their own
|
||||||
/** Built-in device template ids the user has removed from their palette.
|
* private ones, already merged (by Postgres RLS, at fetch time — see
|
||||||
* Hidden rather than deleted since built-ins are shared code, not
|
* state/catalogStore.ts), not scoped to any one diagram. Devices/ports/
|
||||||
* per-project data — this lets them be restored later. */
|
* connections reference these by id (a live reference, not a copy — see
|
||||||
hiddenBuiltInDeviceTemplateIds: string[]
|
* organized-ideas.md §1/§3 for why that's the deliberate choice).
|
||||||
|
*/
|
||||||
|
export interface Catalog {
|
||||||
|
portTypes: PortType[]
|
||||||
|
cableTypes: CableType[]
|
||||||
|
deviceCategories: DeviceCategoryDef[]
|
||||||
|
deviceTemplates: DeviceTemplate[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
|
||||||
|
import type { CatalogRepository } from '../data/CatalogRepository'
|
||||||
|
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
|
||||||
|
|
||||||
|
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
|
||||||
|
// allDeviceTemplates/hiddenDeviceTemplates), never the shared entry itself.
|
||||||
|
const HIDDEN_PUBLIC_TEMPLATES_KEY = 'av-planner:hiddenPublicDeviceTemplateIds'
|
||||||
|
function loadHiddenPublicIds(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(HIDDEN_PUBLIC_TEMPLATES_KEY)
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set()
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveHiddenPublicIds(ids: Set<string>): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(HIDDEN_PUBLIC_TEMPLATES_KEY, JSON.stringify([...ids]))
|
||||||
|
} catch {
|
||||||
|
// Not essential — worst case a hidden template reappears next launch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], deviceTemplates: [] }
|
||||||
|
|
||||||
|
interface CatalogStoreState {
|
||||||
|
catalog: Catalog
|
||||||
|
isLoaded: boolean
|
||||||
|
hiddenPublicDeviceTemplateIds: Set<string>
|
||||||
|
|
||||||
|
loadCatalog: () => Promise<void>
|
||||||
|
|
||||||
|
addCustomDeviceCategory: (name: string) => Promise<string>
|
||||||
|
|
||||||
|
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
|
||||||
|
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
|
||||||
|
|
||||||
|
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
|
||||||
|
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
|
||||||
|
|
||||||
|
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
|
||||||
|
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
|
||||||
|
removeCustomDeviceTemplate: (id: string) => Promise<void>
|
||||||
|
|
||||||
|
hidePublicDeviceTemplate: (id: string) => void
|
||||||
|
restorePublicDeviceTemplate: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
||||||
|
catalog: emptyCatalog,
|
||||||
|
isLoaded: false,
|
||||||
|
hiddenPublicDeviceTemplateIds: loadHiddenPublicIds(),
|
||||||
|
|
||||||
|
loadCatalog: async () => {
|
||||||
|
const catalog = await repository.load()
|
||||||
|
set({ catalog, isLoaded: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
addCustomDeviceCategory: async (name) => {
|
||||||
|
const category = await repository.addDeviceCategory(name)
|
||||||
|
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
|
||||||
|
return category.id
|
||||||
|
},
|
||||||
|
|
||||||
|
addCustomPortType: async (portType) => {
|
||||||
|
const created = await repository.addPortType(portType)
|
||||||
|
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomPortType: async (id, patch) => {
|
||||||
|
await repository.updatePortType(id, patch)
|
||||||
|
set((state) => ({
|
||||||
|
catalog: {
|
||||||
|
...state.catalog,
|
||||||
|
portTypes: state.catalog.portTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
addCustomCableType: async (cableType) => {
|
||||||
|
const created = await repository.addCableType(cableType)
|
||||||
|
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomCableType: async (id, patch) => {
|
||||||
|
await repository.updateCableType(id, patch)
|
||||||
|
set((state) => ({
|
||||||
|
catalog: {
|
||||||
|
...state.catalog,
|
||||||
|
cableTypes: state.catalog.cableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
addCustomDeviceTemplate: async (template) => {
|
||||||
|
const created = await repository.addDeviceTemplate(template)
|
||||||
|
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomDeviceTemplate: async (id, patch) => {
|
||||||
|
await repository.updateDeviceTemplate(id, patch)
|
||||||
|
set((state) => ({
|
||||||
|
catalog: {
|
||||||
|
...state.catalog,
|
||||||
|
deviceTemplates: state.catalog.deviceTemplates.map((t) => (t.id === id ? { ...t, ...patch } : t)),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCustomDeviceTemplate: async (id) => {
|
||||||
|
await repository.removeDeviceTemplate(id)
|
||||||
|
set((state) => ({
|
||||||
|
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
hidePublicDeviceTemplate: (id) => {
|
||||||
|
const next = new Set(get().hiddenPublicDeviceTemplateIds)
|
||||||
|
next.add(id)
|
||||||
|
saveHiddenPublicIds(next)
|
||||||
|
set({ hiddenPublicDeviceTemplateIds: next })
|
||||||
|
},
|
||||||
|
|
||||||
|
restorePublicDeviceTemplate: (id) => {
|
||||||
|
const next = new Set(get().hiddenPublicDeviceTemplateIds)
|
||||||
|
next.delete(id)
|
||||||
|
saveHiddenPublicIds(next)
|
||||||
|
set({ hiddenPublicDeviceTemplateIds: next })
|
||||||
|
},
|
||||||
|
}))
|
||||||
+16
-127
@@ -2,7 +2,7 @@ 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, createEmptyProject, incrementPortName } from '../domain/project'
|
||||||
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
|
import type { Catalog, Connection, Device, DeviceCategory, Port, Project } from '../domain/types'
|
||||||
import type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
import type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
||||||
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ interface ProjectStoreState {
|
|||||||
selectDevice: (id: string | null) => void
|
selectDevice: (id: string | null) => void
|
||||||
selectConnection: (id: string | null) => void
|
selectConnection: (id: string | null) => void
|
||||||
|
|
||||||
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
|
addDeviceFromTemplate: (catalog: Catalog, templateId: string, position: { x: number; y: number }) => void
|
||||||
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
||||||
updateDevice: (
|
updateDevice: (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -90,31 +90,21 @@ interface ProjectStoreState {
|
|||||||
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
|
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
|
||||||
removePort: (deviceId: string, portId: string) => void
|
removePort: (deviceId: string, portId: string) => void
|
||||||
|
|
||||||
addConnection: (params: {
|
addConnection: (
|
||||||
sourceDeviceId: string
|
catalog: Catalog,
|
||||||
sourcePortId: string
|
params: {
|
||||||
targetDeviceId: string
|
sourceDeviceId: string
|
||||||
targetPortId: string
|
sourcePortId: string
|
||||||
cableTypeId: string
|
targetDeviceId: string
|
||||||
}) => boolean
|
targetPortId: string
|
||||||
|
cableTypeId: string
|
||||||
|
},
|
||||||
|
) => boolean
|
||||||
updateConnection: (
|
updateConnection: (
|
||||||
id: string,
|
id: string,
|
||||||
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
||||||
) => void
|
) => void
|
||||||
removeConnection: (id: string) => void
|
removeConnection: (id: string) => void
|
||||||
|
|
||||||
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void
|
|
||||||
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'builtIn'>>) => void
|
|
||||||
removeCustomDeviceTemplate: (id: string) => void
|
|
||||||
hideBuiltInDeviceTemplate: (id: string) => void
|
|
||||||
restoreBuiltInDeviceTemplate: (id: string) => void
|
|
||||||
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => void
|
|
||||||
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => void
|
|
||||||
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => void
|
|
||||||
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => void
|
|
||||||
|
|
||||||
/** Returns the new category's id, so the caller can select it immediately. */
|
|
||||||
addCustomDeviceCategory: (name: string) => string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function touch(project: Project): Project {
|
function touch(project: Project): Project {
|
||||||
@@ -185,9 +175,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
|
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
|
||||||
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
|
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
|
||||||
|
|
||||||
addDeviceFromTemplate: (templateId, position) => {
|
addDeviceFromTemplate: (catalog, templateId, position) => {
|
||||||
const { project } = get()
|
const { project } = get()
|
||||||
const template = allDeviceTemplates(project).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({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id })
|
||||||
@@ -282,9 +272,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
addConnection: ({ sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
|
addConnection: (catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
|
||||||
const { project } = get()
|
const { project } = get()
|
||||||
const validation = validateConnection(project, {
|
const validation = validateConnection(project, catalog, {
|
||||||
sourceDeviceId,
|
sourceDeviceId,
|
||||||
sourcePortId,
|
sourcePortId,
|
||||||
targetDeviceId,
|
targetDeviceId,
|
||||||
@@ -330,107 +320,6 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
addCustomDeviceTemplate: (template) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customDeviceTemplates: [
|
|
||||||
...state.project.customDeviceTemplates,
|
|
||||||
{ ...template, id: uuid() },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
updateCustomDeviceTemplate: (id, patch) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customDeviceTemplates: state.project.customDeviceTemplates.map((t) =>
|
|
||||||
t.id === id ? { ...t, ...patch } : t,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
removeCustomDeviceTemplate: (id) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customDeviceTemplates: state.project.customDeviceTemplates.filter((t) => t.id !== id),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
hideBuiltInDeviceTemplate: (id) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.includes(id)
|
|
||||||
? state.project.hiddenBuiltInDeviceTemplateIds
|
|
||||||
: [...state.project.hiddenBuiltInDeviceTemplateIds, id],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
restoreBuiltInDeviceTemplate: (id) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.filter((h) => h !== id),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
addCustomPortType: (portType) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customPortTypes: [...state.project.customPortTypes, { ...portType, id: uuid(), custom: true }],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
updateCustomPortType: (id, patch) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customPortTypes: state.project.customPortTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
addCustomCableType: (cableType) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customCableTypes: [...state.project.customCableTypes, { ...cableType, id: uuid(), custom: true }],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
updateCustomCableType: (id, patch) => {
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customCableTypes: state.project.customCableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
addCustomDeviceCategory: (name) => {
|
|
||||||
const newId = uuid()
|
|
||||||
set((state) => ({
|
|
||||||
project: touch({
|
|
||||||
...state.project,
|
|
||||||
customDeviceCategories: [
|
|
||||||
...state.project.customDeviceCategories,
|
|
||||||
{ id: newId, name, custom: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
return newId
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Debounced autosave: fires whenever `project` changes (skipped until the
|
// Debounced autosave: fires whenever `project` changes (skipped until the
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- device_templates currently has manufacturer_id (a FK into the normalized
|
||||||
|
-- `manufacturers` table), but nothing in the app populates or reads it yet —
|
||||||
|
-- organized-ideas.md §3 calls for manufacturer to eventually be a normalized,
|
||||||
|
-- submittable catalog like device categories, but wiring that up (browse-by-
|
||||||
|
-- manufacturer view, its own submission flow) is its own chunk of work.
|
||||||
|
-- For this pass (public catalog read integration), keep manufacturer as
|
||||||
|
-- plain free text on the template, matching the domain model's existing
|
||||||
|
-- `DeviceTemplate.manufacturer?: string` — the same shape it's always had
|
||||||
|
-- when templates lived embedded in each diagram. manufacturer_id is left in
|
||||||
|
-- place, unused, for the future normalization pass rather than dropped.
|
||||||
|
alter table public.device_templates add column manufacturer text;
|
||||||
+28
-28
@@ -86,40 +86,40 @@ insert into public.cable_types (id, name, family, family2, unit, is_public) valu
|
|||||||
|
|
||||||
-- Device templates + their ports
|
-- Device templates + their ports
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-display', 'Display (TV / Projector)', 'display', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-display', 'Display (TV / Projector)', 'display', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('62079818-e050-4dee-8065-4eedd40f4c02', 'dt-display', 'HDMI In 1', 'input', 'pt-hdmi', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('d88055fb-9fd7-4dfc-b9a0-0c9b508164cd', 'dt-display', 'HDMI In 1', 'input', 'pt-hdmi', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('04eeee4d-ac8e-4aef-a2ae-1e4dd2e4fc2a', 'dt-display', 'HDMI In 2', 'input', 'pt-hdmi', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('5912e28d-dd20-41ae-b1db-4f54be9d4c50', 'dt-display', 'HDMI In 2', 'input', 'pt-hdmi', 1);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('182dd666-cfe7-489a-97e4-78e2fdf91668', 'dt-display', 'Power In', 'input', 'pt-iec-power', 2);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('6f401c75-9abf-4662-92b8-78544641c408', 'dt-display', 'Power In', 'input', 'pt-iec-power', 2);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-media-player', 'Media Player / Streamer', 'video-source', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-media-player', 'Media Player / Streamer', 'video-source', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('21d1e934-948b-4060-afc1-8f8827223ed7', 'dt-media-player', 'HDMI Out', 'output', 'pt-hdmi', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('ff7d299c-9c29-4f78-8045-5d7ede0ce67a', 'dt-media-player', 'HDMI Out', 'output', 'pt-hdmi', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('e9123870-9f39-4703-9d2c-56cff2bf5162', 'dt-media-player', 'Power In', 'input', 'pt-nema-power', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('5dd62566-e768-4945-a634-b86af3de8aea', 'dt-media-player', 'Power In', 'input', 'pt-nema-power', 1);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-avr', 'AV Receiver', 'amplifier', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-avr', 'AV Receiver', 'amplifier', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('1ab3b4b4-51db-46bf-b473-42203cc522d9', 'dt-avr', 'HDMI In 1', 'input', 'pt-hdmi', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('d013105c-bf1e-4031-9457-a5d13257b32c', 'dt-avr', 'HDMI In 1', 'input', 'pt-hdmi', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('66536c92-a719-46a1-bb5e-4e15b4e3aa15', 'dt-avr', 'HDMI In 2', 'input', 'pt-hdmi', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('9fb7d9ae-1ef4-4cf0-8c42-16b17a7bce39', 'dt-avr', 'HDMI In 2', 'input', 'pt-hdmi', 1);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('6b4611a2-f2ec-409f-a89d-97bfa70fdd1d', 'dt-avr', 'HDMI In 3', 'input', 'pt-hdmi', 2);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('bdb31007-8551-41c9-8a1d-13d5a5d13c6d', 'dt-avr', 'HDMI In 3', 'input', 'pt-hdmi', 2);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('345d6b0e-d260-4b7c-be62-cadd581890ec', 'dt-avr', 'HDMI Out (Display)', 'output', 'pt-hdmi', 3);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('5c0203a1-5a8d-46fa-9561-c2b641d5cdcf', 'dt-avr', 'HDMI Out (Display)', 'output', 'pt-hdmi', 3);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('07d68503-7f75-4783-be3f-db34b3066f83', 'dt-avr', 'Speaker Out L', 'output', 'pt-speaker-wire', 4);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('3b32c9cc-d3b5-4487-af58-dea5580dc888', 'dt-avr', 'Speaker Out L', 'output', 'pt-speaker-wire', 4);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('f68bf47e-dd17-4b64-84d9-ada7b3b75132', 'dt-avr', 'Speaker Out R', 'output', 'pt-speaker-wire', 5);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('fa5ff4f6-2f28-426e-adb5-5f2b4b42ca8a', 'dt-avr', 'Speaker Out R', 'output', 'pt-speaker-wire', 5);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('7c6780fd-a26c-4c09-ab6c-5417ed9a31de', 'dt-avr', 'Power In', 'input', 'pt-iec-power', 6);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('88e76400-320f-4d2d-a653-b760de6447ea', 'dt-avr', 'Power In', 'input', 'pt-iec-power', 6);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-speaker', 'Speaker (Passive)', 'speaker', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-speaker', 'Speaker (Passive)', 'speaker', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('3a0157b0-635a-43cb-b874-446c70ab32f2', 'dt-speaker', 'Speaker In', 'input', 'pt-speaker-wire', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('f8586575-70c2-453f-8608-ddc2e54810e4', 'dt-speaker', 'Speaker In', 'input', 'pt-speaker-wire', 0);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-switch', 'Network Switch', 'network', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-switch', 'Network Switch', 'network', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('8167e698-3693-4256-aa84-80de3638710c', 'dt-switch', 'Port 1', 'bidirectional', 'pt-cat-rj45', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('3d8a38ca-652a-489e-8b0c-07207ecd5ec3', 'dt-switch', 'Port 1', 'bidirectional', 'pt-cat-rj45', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('ce4e8e83-0c91-422d-88df-6caf6af5a145', 'dt-switch', 'Port 2', 'bidirectional', 'pt-cat-rj45', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('28ff234a-3985-4487-82b2-0df600fe2bb9', 'dt-switch', 'Port 2', 'bidirectional', 'pt-cat-rj45', 1);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('f215e7ab-2c12-46c9-989e-ee6d55c2fa1f', 'dt-switch', 'Port 3', 'bidirectional', 'pt-cat-rj45', 2);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('92a972be-7584-4a2f-96c8-69631040c0a2', 'dt-switch', 'Port 3', 'bidirectional', 'pt-cat-rj45', 2);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('baf456fc-643a-42e4-8981-ce22eefa94b3', 'dt-switch', 'Port 4', 'bidirectional', 'pt-cat-rj45', 3);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('d30c0ccf-8990-4935-af2e-6201817b12ad', 'dt-switch', 'Port 4', 'bidirectional', 'pt-cat-rj45', 3);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('42c14e32-7315-4008-a8d9-32621e4d9569', 'dt-switch', 'Power In', 'input', 'pt-iec-power', 4);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('b948a928-f397-494e-9e63-fd6bd2efae87', 'dt-switch', 'Power In', 'input', 'pt-iec-power', 4);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-camera', 'PTZ Camera', 'camera', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-camera', 'PTZ Camera', 'camera', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('83e946f2-2ad3-4e31-889d-fccddf2bebde', 'dt-camera', 'SDI Out', 'output', 'pt-sdi', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('4ba62e67-b3b2-4113-aef3-fa4e9a61b3d5', 'dt-camera', 'SDI Out', 'output', 'pt-sdi', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('439abff2-ff23-4bc1-b096-6f67e9a6fc32', 'dt-camera', 'Control (RJ45)', 'bidirectional', 'pt-cat-rj45', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('65cee659-d23c-42d6-9791-bdf957ea5186', 'dt-camera', 'Control (RJ45)', 'bidirectional', 'pt-cat-rj45', 1);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('ea9355fc-3668-4085-9787-5439d9485a04', 'dt-camera', 'Power In', 'input', 'pt-nema-power', 2);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('b9b359d6-d796-4972-9259-d930339d8007', 'dt-camera', 'Power In', 'input', 'pt-nema-power', 2);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-computer', 'Computer / Laptop', 'computer', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-computer', 'Computer / Laptop', 'computer', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('c8bd2521-5227-419c-a437-d319cf4f90ba', 'dt-computer', 'HDMI Out', 'output', 'pt-hdmi', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('1f050620-7f3d-427e-a682-f18afec5d582', 'dt-computer', 'HDMI Out', 'output', 'pt-hdmi', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('73c8c3fb-9ee7-4f39-a241-449bf5ce36ea', 'dt-computer', 'USB-C', 'bidirectional', 'pt-usb-c', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('ebded1f5-83e9-4942-b6cd-3c8fa54f71c0', 'dt-computer', 'USB-C', 'bidirectional', 'pt-usb-c', 1);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('e417fea4-acf6-44a2-848f-a479003a1ee1', 'dt-computer', 'Ethernet', 'bidirectional', 'pt-cat-rj45', 2);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('4159750f-b334-4f45-848d-11ebb4168ff2', 'dt-computer', 'Ethernet', 'bidirectional', 'pt-cat-rj45', 2);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-adapter-midi-trs-a-to-din', '3.5mm-to-DIN MIDI Adapter (Type A)', 'adapter', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-adapter-midi-trs-a-to-din', '3.5mm-to-DIN MIDI Adapter (Type A)', 'adapter', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('5b295fb4-290f-4800-ba5f-4ad2b314717a', 'dt-adapter-midi-trs-a-to-din', '3.5mm In (from MIDI Out)', 'input', 'pt-midi-trs-a', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('367bf562-4530-4ba8-8576-08a8e0148d3a', 'dt-adapter-midi-trs-a-to-din', '3.5mm In (from MIDI Out)', 'input', 'pt-midi-trs-a', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('cdfb5cf4-848f-4389-bacd-9858f85ce6fd', 'dt-adapter-midi-trs-a-to-din', 'DIN Out (to MIDI In)', 'output', 'pt-midi-din', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('56f8238f-204b-4793-992d-ab197915009c', 'dt-adapter-midi-trs-a-to-din', 'DIN Out (to MIDI In)', 'output', 'pt-midi-din', 1);
|
||||||
insert into public.device_templates (id, name, category_id, is_public) values ('dt-adapter-midi-trs-b-to-din', '3.5mm-to-DIN MIDI Adapter (Type B)', 'adapter', true);
|
insert into public.device_templates (id, name, category_id, is_public) values ('dt-adapter-midi-trs-b-to-din', '3.5mm-to-DIN MIDI Adapter (Type B)', 'adapter', true);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('c1f2e564-8b14-483e-a719-633ffa1901e3', 'dt-adapter-midi-trs-b-to-din', '3.5mm In (from MIDI Out)', 'input', 'pt-midi-trs-b', 0);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('30695330-998c-4ffd-b672-eb746107b63c', 'dt-adapter-midi-trs-b-to-din', '3.5mm In (from MIDI Out)', 'input', 'pt-midi-trs-b', 0);
|
||||||
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('8b5d138d-d936-47a9-a625-dafc184c1e7f', 'dt-adapter-midi-trs-b-to-din', 'DIN Out (to MIDI In)', 'output', 'pt-midi-din', 1);
|
insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order) values ('15a3e559-d725-4fb2-a419-c649c4da9c16', 'dt-adapter-midi-trs-b-to-din', 'DIN Out (to MIDI In)', 'output', 'pt-midi-din', 1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user