Add multi-diagram management
- DiagramRepository interface redesigned around multiple diagrams: list()/loadById()/deleteById() replace the old single-diagram load()/clear(). Both implementations updated to match. - SupabaseDiagramRepository.save() now does an explicit update-or-insert instead of a blind upsert, so owner_id is only ever set at creation -- an upsert would resend it on every save and let whoever saves last silently reassign ownership. Not reachable yet (no collaborator UI), but a real landmine once diagram sharing (organized-ideas.md §8) lands, and cheap to avoid now. - LocalStorageDiagramRepository now stores diagrams keyed by id (was a single fixed key), keeping it a genuine working fallback rather than a stale reference implementing an old interface. - Store: loadInitialDiagram (renamed from loadFromStorage) opens the last diagram you had open (tracked in localStorage -- a UI preference, not app data), falling back to the most recently updated one, falling back to a fresh empty diagram. New actions: refreshDiagramList, switchToDiagram, deleteDiagram. newProject and importProject now persist immediately (not just via the debounced autosave) so a new/imported diagram shows up in the list right away; importProject also assigns a fresh id so it can't collide with an existing diagram. - New DiagramManagerModal (list/open/delete/+New), opened from a "Diagrams" button in TopBar that replaces the old single-diagram "New" button and its now-unnecessary confirmation dialog -- nothing is lost by creating a new diagram anymore, since the old one stays saved and reachable from the list. Verified insert/list/update(-preserves-owner)/loadById/delete against the real local stack; RLS test suite still 23/23 after a fresh reset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
@@ -8,11 +8,11 @@ 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 loadFromStorage = useProjectStore((s) => s.loadFromStorage)
|
const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFromStorage()
|
loadInitialDiagram()
|
||||||
}, [loadFromStorage])
|
}, [loadInitialDiagram])
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
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>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
function formatUpdatedAt(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagramManagerModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const diagramList = useProjectStore((s) => s.diagramList)
|
||||||
|
const refreshDiagramList = useProjectStore((s) => s.refreshDiagramList)
|
||||||
|
const newProject = useProjectStore((s) => s.newProject)
|
||||||
|
const switchToDiagram = useProjectStore((s) => s.switchToDiagram)
|
||||||
|
const deleteDiagram = useProjectStore((s) => s.deleteDiagram)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshDiagramList()
|
||||||
|
}, [refreshDiagramList])
|
||||||
|
|
||||||
|
const handleOpen = async (id: string) => {
|
||||||
|
if (id === project.id) return
|
||||||
|
await switchToDiagram(id)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNew = async () => {
|
||||||
|
await newProject()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
diagramList.length === 1
|
||||||
|
? `Delete "${name}"? This is your only diagram, so a fresh empty one will replace it.`
|
||||||
|
: `Delete "${name}"? This can't be undone.`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
await deleteDiagram(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Diagrams" onClose={onClose} width="max-w-lg">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={handleNew}
|
||||||
|
className="w-full rounded bg-indigo-600 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
+ New Diagram
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{diagramList.map((d) => {
|
||||||
|
const isCurrent = d.id === project.id
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className={`group flex items-center justify-between gap-2 rounded border px-3 py-2 text-xs ${
|
||||||
|
isCurrent ? 'border-indigo-300 bg-indigo-50' : 'border-slate-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpen(d.id)}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className="min-w-0 flex-1 text-left disabled:cursor-default"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium text-slate-700">{d.name}</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="shrink-0 rounded bg-indigo-100 px-1 text-[10px] font-medium text-indigo-700">
|
||||||
|
current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[10px] text-slate-400">Updated {formatUpdatedAt(d.updatedAt)}</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(d.id, d.name)}
|
||||||
|
title="Delete this diagram"
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-slate-400 opacity-0 hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{diagramList.length === 0 && (
|
||||||
|
<p className="py-2 text-center text-xs italic text-slate-400">No diagrams yet.</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,15 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
||||||
import { supabase } from '../../data/supabaseClient'
|
import { supabase } from '../../data/supabaseClient'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import DiagramManagerModal from './DiagramManagerModal'
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const renameProject = useProjectStore((s) => s.renameProject)
|
const renameProject = useProjectStore((s) => s.renameProject)
|
||||||
const newProject = useProjectStore((s) => s.newProject)
|
|
||||||
const importProject = useProjectStore((s) => s.importProject)
|
const importProject = useProjectStore((s) => s.importProject)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
||||||
const handleNewProject = () => {
|
|
||||||
if (project.devices.length > 0 || project.connections.length > 0) {
|
|
||||||
const confirmed = window.confirm('Start a new project? Unsaved changes to the current one will be lost (unless exported).')
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
newProject()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExport = () => downloadProjectFile(project)
|
const handleExport = () => downloadProjectFile(project)
|
||||||
|
|
||||||
@@ -28,7 +21,7 @@ export default function TopBar() {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
const imported = await readProjectFile(file)
|
const imported = await readProjectFile(file)
|
||||||
importProject(imported)
|
await importProject(imported)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.'
|
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.'
|
||||||
window.alert(`Import failed: ${message}`)
|
window.alert(`Import failed: ${message}`)
|
||||||
@@ -46,8 +39,11 @@ export default function TopBar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button onClick={handleNewProject} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button
|
||||||
New
|
onClick={() => setDiagramManagerOpen(true)}
|
||||||
|
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Diagrams
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
Import
|
Import
|
||||||
@@ -66,6 +62,7 @@ export default function TopBar() {
|
|||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { Project } from '../domain/types'
|
import type { Project } from '../domain/types'
|
||||||
|
|
||||||
|
/** Lightweight metadata for listing diagrams without fetching each one's
|
||||||
|
* full (potentially large) data payload. */
|
||||||
|
export interface DiagramSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
|
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
|
||||||
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
|
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
|
||||||
@@ -12,7 +20,12 @@ import type { Project } from '../domain/types'
|
|||||||
* across the whole app, tracked as its own task rather than bundled into this one.
|
* across the whole app, tracked as its own task rather than bundled into this one.
|
||||||
*/
|
*/
|
||||||
export interface DiagramRepository {
|
export interface DiagramRepository {
|
||||||
load(): Promise<Project | null>
|
/** Every diagram visible to the current user, most recently updated first. */
|
||||||
|
list(): Promise<DiagramSummary[]>
|
||||||
|
/** One diagram's full data by id, or null if it doesn't exist / isn't visible. */
|
||||||
|
loadById(id: string): Promise<Project | null>
|
||||||
|
/** Upsert a diagram, matched by the project's own id. */
|
||||||
save(project: Project): Promise<void>
|
save(project: Project): Promise<void>
|
||||||
clear(): Promise<void>
|
/** Permanently delete one diagram by id. */
|
||||||
|
deleteById(id: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,51 @@
|
|||||||
import type { Project } from '../domain/types'
|
import type { Project } from '../domain/types'
|
||||||
import type { DiagramRepository } from './DiagramRepository'
|
import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
|
||||||
import { normalizeProject } from './exportImport'
|
import { normalizeProject } from './exportImport'
|
||||||
|
|
||||||
const STORAGE_KEY = 'av-planner:project:v1'
|
const STORAGE_KEY = 'av-planner:diagrams:v1'
|
||||||
|
|
||||||
/** Kept around (no longer the active repository — see projectStore.ts) as a
|
type StoredDiagrams = Record<string, Project>
|
||||||
* reference implementation and a safe fallback if Supabase is unreachable. */
|
|
||||||
export class LocalStorageDiagramRepository implements DiagramRepository {
|
function readAll(): StoredDiagrams {
|
||||||
async load(): Promise<Project | null> {
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return null
|
return raw ? (JSON.parse(raw) as StoredDiagrams) : {}
|
||||||
return normalizeProject(JSON.parse(raw) as Project)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project from localStorage', err)
|
console.error('Failed to read diagrams from localStorage', err)
|
||||||
return null
|
return {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(diagrams: StoredDiagrams): void {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(diagrams))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept around (no longer the active repository — see projectStore.ts) as a
|
||||||
|
* reference implementation and a safe fallback if Supabase is unreachable.
|
||||||
|
* Multiple diagrams are stored as one JSON object keyed by id, matching the
|
||||||
|
* shape the Supabase-backed repository presents to the rest of the app. */
|
||||||
|
export class LocalStorageDiagramRepository implements DiagramRepository {
|
||||||
|
async list(): Promise<DiagramSummary[]> {
|
||||||
|
const diagrams = Object.values(readAll())
|
||||||
|
return diagrams
|
||||||
|
.map((d) => ({ id: d.id, name: d.name, updatedAt: d.updatedAt }))
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadById(id: string): Promise<Project | null> {
|
||||||
|
const diagram = readAll()[id]
|
||||||
|
return diagram ? normalizeProject(diagram) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(project: Project): Promise<void> {
|
async save(project: Project): Promise<void> {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
|
const all = readAll()
|
||||||
|
all[project.id] = project
|
||||||
|
writeAll(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async deleteById(id: string): Promise<void> {
|
||||||
window.localStorage.removeItem(STORAGE_KEY)
|
const all = readAll()
|
||||||
|
delete all[id]
|
||||||
|
writeAll(all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Project } from '../domain/types'
|
import type { Project } from '../domain/types'
|
||||||
import type { DiagramRepository } from './DiagramRepository'
|
import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
|
||||||
import { normalizeProject } from './exportImport'
|
import { normalizeProject } from './exportImport'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
@@ -10,24 +10,25 @@ import { supabase } from './supabaseClient'
|
|||||||
* migration's header comment for why diagrams are a document, not
|
* migration's header comment for why diagrams are a document, not
|
||||||
* normalized relational rows.
|
* normalized relational rows.
|
||||||
*
|
*
|
||||||
* Single-diagram scope for now, matching the app's current UI (one active
|
* RLS already scopes every query here to what the signed-in user can see
|
||||||
* project at a time): `load()` returns the signed-in user's most recently
|
* (their own diagrams, plus anything shared with them per organized-ideas.md
|
||||||
* updated diagram, `save()` upserts by the diagram's own id. Multi-diagram
|
* §8) — no need to filter by owner_id client-side.
|
||||||
* management (list/switch between several) is a later phase.
|
|
||||||
*/
|
*/
|
||||||
export class SupabaseDiagramRepository implements DiagramRepository {
|
export class SupabaseDiagramRepository implements DiagramRepository {
|
||||||
async load(): Promise<Project | null> {
|
async list(): Promise<DiagramSummary[]> {
|
||||||
const {
|
const { data, error } = await supabase.from('diagrams').select('id, name, updated_at').order('updated_at', {
|
||||||
data: { user },
|
ascending: false,
|
||||||
} = await supabase.auth.getUser()
|
})
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
const { data: row, error } = await supabase
|
if (error) {
|
||||||
.from('diagrams')
|
console.error('Failed to list diagrams from Supabase', error)
|
||||||
.select('data')
|
return []
|
||||||
.order('updated_at', { ascending: false })
|
}
|
||||||
.limit(1)
|
return (data ?? []).map((row) => ({ id: row.id, name: row.name, updatedAt: row.updated_at }))
|
||||||
.maybeSingle()
|
}
|
||||||
|
|
||||||
|
async loadById(id: string): Promise<Project | null> {
|
||||||
|
const { data: row, error } = await supabase.from('diagrams').select('data').eq('id', id).maybeSingle()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to load diagram from Supabase', error)
|
console.error('Failed to load diagram from Supabase', error)
|
||||||
@@ -46,28 +47,27 @@ export class SupabaseDiagramRepository implements DiagramRepository {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from('diagrams').upsert({
|
// Deliberately update-or-insert rather than a blind upsert: an upsert
|
||||||
id: project.id,
|
// would resend owner_id on every save and silently reassign ownership
|
||||||
name: project.name,
|
// to whoever saves last. Not reachable yet (no collaborator UI exists),
|
||||||
owner_id: user.id,
|
// but once diagram sharing (§8) lands, an edit-collaborator saving
|
||||||
data: project,
|
// changes must never be able to overwrite who owns the diagram.
|
||||||
updated_at: new Date().toISOString(),
|
const { data: existing } = await supabase.from('diagrams').select('id').eq('id', project.id).maybeSingle()
|
||||||
})
|
const payload = { name: project.name, data: project, updated_at: new Date().toISOString() }
|
||||||
|
|
||||||
|
const { error } = existing
|
||||||
|
? await supabase.from('diagrams').update(payload).eq('id', project.id)
|
||||||
|
: await supabase.from('diagrams').insert({ id: project.id, owner_id: user.id, ...payload })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to save diagram to Supabase', error)
|
console.error('Failed to save diagram to Supabase', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async deleteById(id: string): Promise<void> {
|
||||||
const {
|
const { error } = await supabase.from('diagrams').delete().eq('id', id)
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
if (!user) return
|
|
||||||
|
|
||||||
const { error } = await supabase.from('diagrams').delete().eq('owner_id', user.id)
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to clear diagrams from Supabase', error)
|
console.error('Failed to delete diagram from Supabase', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-11
@@ -3,7 +3,7 @@ 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 { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
|
||||||
import type { DiagramRepository } from '../data/DiagramRepository'
|
import type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
||||||
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
||||||
|
|
||||||
// This is exactly the swap the repository pattern was built for: only this
|
// This is exactly the swap the repository pattern was built for: only this
|
||||||
@@ -13,18 +13,63 @@ import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
|||||||
// codebase as a reference implementation / fallback, just no longer active.
|
// codebase as a reference implementation / fallback, just no longer active.
|
||||||
const repository: DiagramRepository = new SupabaseDiagramRepository()
|
const repository: DiagramRepository = new SupabaseDiagramRepository()
|
||||||
|
|
||||||
|
// Which diagram to reopen on next launch — a session/UI preference, not app
|
||||||
|
// data, so it lives in localStorage even though the diagrams themselves are
|
||||||
|
// in Supabase now. Falls back gracefully (see loadInitialDiagram) if the
|
||||||
|
// referenced diagram no longer exists.
|
||||||
|
const LAST_DIAGRAM_ID_KEY = 'av-planner:lastDiagramId'
|
||||||
|
function getLastDiagramId(): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(LAST_DIAGRAM_ID_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setLastDiagramId(id: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LAST_DIAGRAM_ID_KEY, id)
|
||||||
|
} catch {
|
||||||
|
// Not essential — worst case, next launch falls back to the most
|
||||||
|
// recently updated diagram instead of this exact one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMostRecent(): Promise<Project | null> {
|
||||||
|
const list = await repository.list()
|
||||||
|
if (list.length === 0) return null
|
||||||
|
return repository.loadById(list[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectStoreState {
|
interface ProjectStoreState {
|
||||||
project: Project
|
project: Project
|
||||||
isLoaded: boolean
|
isLoaded: boolean
|
||||||
|
/** Every diagram the current user can see, most recently updated first.
|
||||||
|
* Populated by refreshDiagramList — not kept live-synced on every store
|
||||||
|
* change, just refreshed at the moments that matter (initial load, and
|
||||||
|
* whenever the diagram manager UI opens or changes something). */
|
||||||
|
diagramList: DiagramSummary[]
|
||||||
selectedDeviceId: string | null
|
selectedDeviceId: string | null
|
||||||
selectedConnectionId: string | null
|
selectedConnectionId: string | null
|
||||||
lastConnectionError: string | null
|
lastConnectionError: string | null
|
||||||
clearConnectionError: () => void
|
clearConnectionError: () => void
|
||||||
|
|
||||||
loadFromStorage: () => Promise<void>
|
/** Figures out which diagram to open on launch (last one you had open, else
|
||||||
newProject: (name?: string) => void
|
* most recently updated, else a fresh empty one) and loads it. */
|
||||||
|
loadInitialDiagram: () => Promise<void>
|
||||||
|
refreshDiagramList: () => Promise<void>
|
||||||
|
/** Creates a new diagram, makes it current, and persists it immediately
|
||||||
|
* (not just via the debounced autosave) so it shows up in the list right away. */
|
||||||
|
newProject: (name?: string) => Promise<void>
|
||||||
renameProject: (name: string) => void
|
renameProject: (name: string) => void
|
||||||
importProject: (project: Project) => void
|
/** Imports diagram data under a fresh id, so it's added as a new diagram
|
||||||
|
* rather than risking a collision with (or silently overwriting) an
|
||||||
|
* existing one that happens to share the file's id. */
|
||||||
|
importProject: (project: Project) => Promise<void>
|
||||||
|
/** Switches the active diagram to another one of the user's own. */
|
||||||
|
switchToDiagram: (id: string) => Promise<void>
|
||||||
|
/** Permanently deletes a diagram. If it's the currently open one, falls
|
||||||
|
* back to another existing diagram, or a fresh empty one if none remain. */
|
||||||
|
deleteDiagram: (id: string) => Promise<void>
|
||||||
|
|
||||||
selectDevice: (id: string | null) => void
|
selectDevice: (id: string | null) => void
|
||||||
selectConnection: (id: string | null) => void
|
selectConnection: (id: string | null) => void
|
||||||
@@ -79,26 +124,62 @@ function touch(project: Project): Project {
|
|||||||
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
||||||
project: createEmptyProject(),
|
project: createEmptyProject(),
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
diagramList: [],
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
selectedConnectionId: null,
|
selectedConnectionId: null,
|
||||||
lastConnectionError: null,
|
lastConnectionError: null,
|
||||||
clearConnectionError: () => set({ lastConnectionError: null }),
|
clearConnectionError: () => set({ lastConnectionError: null }),
|
||||||
|
|
||||||
loadFromStorage: async () => {
|
loadInitialDiagram: async () => {
|
||||||
const loaded = await repository.load()
|
const lastId = getLastDiagramId()
|
||||||
set({ project: loaded ?? createEmptyProject(), isLoaded: true })
|
const loaded = lastId ? await repository.loadById(lastId) : null
|
||||||
|
const project = loaded ?? (await loadMostRecent()) ?? createEmptyProject()
|
||||||
|
setLastDiagramId(project.id)
|
||||||
|
set({ project, isLoaded: true })
|
||||||
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
newProject: (name = 'Untitled Project') => {
|
refreshDiagramList: async () => {
|
||||||
set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null })
|
const diagramList = await repository.list()
|
||||||
|
set({ diagramList })
|
||||||
|
},
|
||||||
|
|
||||||
|
newProject: async (name = 'Untitled Project') => {
|
||||||
|
const project = createEmptyProject(name)
|
||||||
|
setLastDiagramId(project.id)
|
||||||
|
set({ project, selectedDeviceId: null, selectedConnectionId: null })
|
||||||
|
await repository.save(project)
|
||||||
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
renameProject: (name) => {
|
renameProject: (name) => {
|
||||||
set((state) => ({ project: touch({ ...state.project, name }) }))
|
set((state) => ({ project: touch({ ...state.project, name }) }))
|
||||||
},
|
},
|
||||||
|
|
||||||
importProject: (project) => {
|
importProject: async (project) => {
|
||||||
set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null })
|
const fresh = touch({ ...project, id: uuid() })
|
||||||
|
setLastDiagramId(fresh.id)
|
||||||
|
set({ project: fresh, selectedDeviceId: null, selectedConnectionId: null })
|
||||||
|
await repository.save(fresh)
|
||||||
|
get().refreshDiagramList()
|
||||||
|
},
|
||||||
|
|
||||||
|
switchToDiagram: async (id) => {
|
||||||
|
if (id === get().project.id) return
|
||||||
|
const loaded = await repository.loadById(id)
|
||||||
|
if (!loaded) return
|
||||||
|
setLastDiagramId(loaded.id)
|
||||||
|
set({ project: loaded, selectedDeviceId: null, selectedConnectionId: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDiagram: async (id) => {
|
||||||
|
await repository.deleteById(id)
|
||||||
|
if (get().project.id === id) {
|
||||||
|
const next = (await loadMostRecent()) ?? createEmptyProject()
|
||||||
|
setLastDiagramId(next.id)
|
||||||
|
set({ project: next, selectedDeviceId: null, selectedConnectionId: null })
|
||||||
|
}
|
||||||
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
|
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
|
||||||
|
|||||||
Reference in New Issue
Block a user