- DiagramRepository (renamed from ProjectRepository, per the organized-ideas.md §8 naming decision) now has a Supabase-backed implementation as the active repository. LocalStorageDiagramRepository stays in the codebase as a reference implementation / fallback, just no longer wired in. Only the storage layer's naming changed here -- the domain type, store, and UI copy still say "Project"; that's a separate, larger mechanical rename tracked on its own. - Minimal email/password auth gate (src/components/auth/LoginScreen.tsx) since Supabase RLS requires a real signed-in user to do anything -- this is NOT the Phase 2 experience (Google SSO, polished signup), just enough of the same schema (username + email + password) to make the backend foundation usable end to end before that phase exists. Respects the hard email-verification gate from config.toml. - .env.example documents the required VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY (local dev values, not secrets); .env.local has the actual local values and is gitignored. Verified end-to-end against the local stack: signup creates a confirmed-pending user, the handle_new_user trigger creates their profile, sign-in is blocked until confirmed, and a signed-in session can upsert/read back its own diagram row exactly as the app's save()/load() do it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
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 type { DiagramRepository } from '../data/DiagramRepository'
|
|
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
|
|
|
// This is exactly the swap the repository pattern was built for: only this
|
|
// one line changed to move off localStorage-only and onto real (locally
|
|
// self-hosted, for now) Supabase persistence — nothing else in the store or
|
|
// UI layers needed to change. LocalStorageDiagramRepository is still in the
|
|
// codebase as a reference implementation / fallback, just no longer active.
|
|
const repository: DiagramRepository = new SupabaseDiagramRepository()
|
|
|
|
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' | 'cost'>>,
|
|
) => 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' | 'cost' | '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 }
|