Adds first version of the app. Local storage only.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { create } from 'zustand'
|
||||
import { validateConnection } from '../domain/compatibility'
|
||||
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
|
||||
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
|
||||
import { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository'
|
||||
import type { ProjectRepository } from '../data/ProjectRepository'
|
||||
|
||||
// Swapping to a backend later means constructing a different repository here
|
||||
// (or injecting one) — nothing else in the store changes.
|
||||
const repository: ProjectRepository = new LocalStorageProjectRepository()
|
||||
|
||||
interface ProjectStoreState {
|
||||
project: Project
|
||||
isLoaded: boolean
|
||||
selectedDeviceId: string | null
|
||||
selectedConnectionId: string | null
|
||||
lastConnectionError: string | null
|
||||
clearConnectionError: () => void
|
||||
|
||||
loadFromStorage: () => Promise<void>
|
||||
newProject: (name?: string) => void
|
||||
renameProject: (name: string) => void
|
||||
importProject: (project: Project) => void
|
||||
|
||||
selectDevice: (id: string | null) => void
|
||||
selectConnection: (id: string | null) => void
|
||||
|
||||
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
|
||||
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
||||
updateDevice: (id: string, patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes'>>) => void
|
||||
removeDevice: (id: string) => void
|
||||
|
||||
/** Returns the new port's id, so the caller can move keyboard focus to it. */
|
||||
addPort: (deviceId: string, port: Omit<Port, 'id'>) => string
|
||||
/** Inserts a copy of the port right after the original (name auto-incremented) and
|
||||
* returns the new port's id, so the caller can move keyboard focus to it. */
|
||||
clonePort: (deviceId: string, portId: string) => string
|
||||
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
|
||||
removePort: (deviceId: string, portId: string) => void
|
||||
|
||||
addConnection: (params: {
|
||||
sourceDeviceId: string
|
||||
sourcePortId: string
|
||||
targetDeviceId: string
|
||||
targetPortId: string
|
||||
cableTypeId: string
|
||||
}) => boolean
|
||||
updateConnection: (id: string, patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'label' | 'notes'>>) => 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 {
|
||||
return { ...project, updatedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
||||
project: createEmptyProject(),
|
||||
isLoaded: false,
|
||||
selectedDeviceId: null,
|
||||
selectedConnectionId: null,
|
||||
lastConnectionError: null,
|
||||
clearConnectionError: () => set({ lastConnectionError: null }),
|
||||
|
||||
loadFromStorage: async () => {
|
||||
const loaded = await repository.load()
|
||||
set({ project: loaded ?? createEmptyProject(), isLoaded: true })
|
||||
},
|
||||
|
||||
newProject: (name = 'Untitled Project') => {
|
||||
set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null })
|
||||
},
|
||||
|
||||
renameProject: (name) => {
|
||||
set((state) => ({ project: touch({ ...state.project, name }) }))
|
||||
},
|
||||
|
||||
importProject: (project) => {
|
||||
set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null })
|
||||
},
|
||||
|
||||
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
|
||||
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
|
||||
|
||||
addDeviceFromTemplate: (templateId, position) => {
|
||||
const { project } = get()
|
||||
const template = allDeviceTemplates(project).find((t) => t.id === templateId)
|
||||
if (!template) return
|
||||
const device = createDeviceFromTemplate(template, position)
|
||||
set({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id })
|
||||
},
|
||||
|
||||
updateDevicePosition: (id, position) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) => (d.id === id ? { ...d, position } : d)),
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
updateDevice: (id, patch) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
removeDevice: (id) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.filter((d) => d.id !== id),
|
||||
connections: state.project.connections.filter(
|
||||
(c) => c.sourceDeviceId !== id && c.targetDeviceId !== id,
|
||||
),
|
||||
}),
|
||||
selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId,
|
||||
}))
|
||||
},
|
||||
|
||||
addPort: (deviceId, port) => {
|
||||
const newId = uuid()
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) =>
|
||||
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
return newId
|
||||
},
|
||||
|
||||
updatePort: (deviceId, portId, patch) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) =>
|
||||
d.id === deviceId
|
||||
? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) }
|
||||
: d,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
clonePort: (deviceId, portId) => {
|
||||
const newId = uuid()
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) => {
|
||||
if (d.id !== deviceId) return d
|
||||
const index = d.ports.findIndex((p) => p.id === portId)
|
||||
if (index === -1) return d
|
||||
const source = d.ports[index]
|
||||
const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) }
|
||||
return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] }
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
return newId
|
||||
},
|
||||
|
||||
removePort: (deviceId, portId) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
devices: state.project.devices.map((d) =>
|
||||
d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d,
|
||||
),
|
||||
connections: state.project.connections.filter(
|
||||
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
addConnection: ({ sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
|
||||
const { project } = get()
|
||||
const validation = validateConnection(project, {
|
||||
sourceDeviceId,
|
||||
sourcePortId,
|
||||
targetDeviceId,
|
||||
targetPortId,
|
||||
})
|
||||
if (!validation.valid) {
|
||||
set({ lastConnectionError: validation.reason ?? 'Invalid connection.' })
|
||||
return false
|
||||
}
|
||||
const connection: Connection = {
|
||||
id: uuid(),
|
||||
sourceDeviceId,
|
||||
sourcePortId,
|
||||
targetDeviceId,
|
||||
targetPortId,
|
||||
cableTypeId,
|
||||
}
|
||||
set({
|
||||
project: touch({ ...project, connections: [...project.connections, connection] }),
|
||||
lastConnectionError: null,
|
||||
selectedConnectionId: connection.id,
|
||||
selectedDeviceId: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
updateConnection: (id, patch) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
connections: state.project.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
removeConnection: (id) => {
|
||||
set((state) => ({
|
||||
project: touch({
|
||||
...state.project,
|
||||
connections: state.project.connections.filter((c) => c.id !== id),
|
||||
}),
|
||||
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
|
||||
}))
|
||||
},
|
||||
|
||||
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
|
||||
// initial load completes, so we don't immediately overwrite storage with a
|
||||
// blank project on first mount).
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
useProjectStore.subscribe((state, prevState) => {
|
||||
// Skip the isLoaded:false -> true transition itself (that's the initial
|
||||
// load, not a user edit) and any tick where `project` didn't change.
|
||||
if (!prevState.isLoaded) return
|
||||
if (state.project === prevState.project) return
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveTimeout = setTimeout(() => {
|
||||
repository.save(state.project).catch((err) => console.error('Autosave failed', err))
|
||||
}, 400)
|
||||
})
|
||||
|
||||
export type { DeviceCategory }
|
||||
Reference in New Issue
Block a user