diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index 64e2f4b..a3e5ac9 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -8,11 +8,11 @@ import TopBar from './TopBar'
export default function AppShell() {
const isLoaded = useProjectStore((s) => s.isLoaded)
- const loadFromStorage = useProjectStore((s) => s.loadFromStorage)
+ const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram)
useEffect(() => {
- loadFromStorage()
- }, [loadFromStorage])
+ loadInitialDiagram()
+ }, [loadInitialDiagram])
if (!isLoaded) {
return
Loading…
diff --git a/src/components/layout/DiagramManagerModal.tsx b/src/components/layout/DiagramManagerModal.tsx
new file mode 100644
index 0000000..5c1a6b0
--- /dev/null
+++ b/src/components/layout/DiagramManagerModal.tsx
@@ -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 (
+
+
+
+
+
+ {diagramList.map((d) => {
+ const isCurrent = d.id === project.id
+ return (
+ -
+
+
+
+ )
+ })}
+ {diagramList.length === 0 && (
+ No diagrams yet.
+ )}
+
+
+
+ )
+}
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index c747815..f4c21c9 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -1,22 +1,15 @@
-import { useRef } from 'react'
+import { useRef, useState } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { supabase } from '../../data/supabaseClient'
import { useProjectStore } from '../../state/projectStore'
+import DiagramManagerModal from './DiagramManagerModal'
export default function TopBar() {
const project = useProjectStore((s) => s.project)
const renameProject = useProjectStore((s) => s.renameProject)
- const newProject = useProjectStore((s) => s.newProject)
const importProject = useProjectStore((s) => s.importProject)
const fileInputRef = useRef(null)
-
- 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 [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
const handleExport = () => downloadProjectFile(project)
@@ -28,7 +21,7 @@ export default function TopBar() {
if (!file) return
try {
const imported = await readProjectFile(file)
- importProject(imported)
+ await importProject(imported)
} catch (err) {
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.'
window.alert(`Import failed: ${message}`)
@@ -46,8 +39,11 @@ export default function TopBar() {
/>
-
+ {diagramManagerOpen && setDiagramManagerOpen(false)} />}
)
}
diff --git a/src/data/DiagramRepository.ts b/src/data/DiagramRepository.ts
index 810437a..bd63744 100644
--- a/src/data/DiagramRepository.ts
+++ b/src/data/DiagramRepository.ts
@@ -1,5 +1,13 @@
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`
* 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.
*/
export interface DiagramRepository {
- load(): Promise
+ /** Every diagram visible to the current user, most recently updated first. */
+ list(): Promise
+ /** One diagram's full data by id, or null if it doesn't exist / isn't visible. */
+ loadById(id: string): Promise
+ /** Upsert a diagram, matched by the project's own id. */
save(project: Project): Promise
- clear(): Promise
+ /** Permanently delete one diagram by id. */
+ deleteById(id: string): Promise
}
diff --git a/src/data/LocalStorageDiagramRepository.ts b/src/data/LocalStorageDiagramRepository.ts
index be0e6c1..81ed6d6 100644
--- a/src/data/LocalStorageDiagramRepository.ts
+++ b/src/data/LocalStorageDiagramRepository.ts
@@ -1,28 +1,51 @@
import type { Project } from '../domain/types'
-import type { DiagramRepository } from './DiagramRepository'
+import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeProject } from './exportImport'
-const STORAGE_KEY = 'av-planner:project:v1'
+const STORAGE_KEY = 'av-planner:diagrams:v1'
+
+type StoredDiagrams = Record
+
+function readAll(): StoredDiagrams {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY)
+ return raw ? (JSON.parse(raw) as StoredDiagrams) : {}
+ } catch (err) {
+ console.error('Failed to read diagrams from localStorage', err)
+ 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. */
+ * 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 load(): Promise {
- try {
- const raw = window.localStorage.getItem(STORAGE_KEY)
- if (!raw) return null
- return normalizeProject(JSON.parse(raw) as Project)
- } catch (err) {
- console.error('Failed to load project from localStorage', err)
- return null
- }
+ async list(): Promise {
+ 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 {
+ const diagram = readAll()[id]
+ return diagram ? normalizeProject(diagram) : null
}
async save(project: Project): Promise {
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
+ const all = readAll()
+ all[project.id] = project
+ writeAll(all)
}
- async clear(): Promise {
- window.localStorage.removeItem(STORAGE_KEY)
+ async deleteById(id: string): Promise {
+ const all = readAll()
+ delete all[id]
+ writeAll(all)
}
}
diff --git a/src/data/SupabaseDiagramRepository.ts b/src/data/SupabaseDiagramRepository.ts
index 49398cf..1fe2548 100644
--- a/src/data/SupabaseDiagramRepository.ts
+++ b/src/data/SupabaseDiagramRepository.ts
@@ -1,5 +1,5 @@
import type { Project } from '../domain/types'
-import type { DiagramRepository } from './DiagramRepository'
+import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import { supabase } from './supabaseClient'
@@ -10,24 +10,25 @@ import { supabase } from './supabaseClient'
* migration's header comment for why diagrams are a document, not
* normalized relational rows.
*
- * Single-diagram scope for now, matching the app's current UI (one active
- * project at a time): `load()` returns the signed-in user's most recently
- * updated diagram, `save()` upserts by the diagram's own id. Multi-diagram
- * management (list/switch between several) is a later phase.
+ * RLS already scopes every query here to what the signed-in user can see
+ * (their own diagrams, plus anything shared with them per organized-ideas.md
+ * §8) — no need to filter by owner_id client-side.
*/
export class SupabaseDiagramRepository implements DiagramRepository {
- async load(): Promise {
- const {
- data: { user },
- } = await supabase.auth.getUser()
- if (!user) return null
+ async list(): Promise {
+ const { data, error } = await supabase.from('diagrams').select('id, name, updated_at').order('updated_at', {
+ ascending: false,
+ })
- const { data: row, error } = await supabase
- .from('diagrams')
- .select('data')
- .order('updated_at', { ascending: false })
- .limit(1)
- .maybeSingle()
+ if (error) {
+ console.error('Failed to list diagrams from Supabase', error)
+ return []
+ }
+ return (data ?? []).map((row) => ({ id: row.id, name: row.name, updatedAt: row.updated_at }))
+ }
+
+ async loadById(id: string): Promise {
+ const { data: row, error } = await supabase.from('diagrams').select('data').eq('id', id).maybeSingle()
if (error) {
console.error('Failed to load diagram from Supabase', error)
@@ -46,28 +47,27 @@ export class SupabaseDiagramRepository implements DiagramRepository {
return
}
- const { error } = await supabase.from('diagrams').upsert({
- id: project.id,
- name: project.name,
- owner_id: user.id,
- data: project,
- updated_at: new Date().toISOString(),
- })
+ // Deliberately update-or-insert rather than a blind upsert: an upsert
+ // would resend owner_id on every save and silently reassign ownership
+ // to whoever saves last. Not reachable yet (no collaborator UI exists),
+ // but once diagram sharing (§8) lands, an edit-collaborator saving
+ // changes must never be able to overwrite who owns the diagram.
+ 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) {
console.error('Failed to save diagram to Supabase', error)
}
}
- async clear(): Promise {
- const {
- data: { user },
- } = await supabase.auth.getUser()
- if (!user) return
-
- const { error } = await supabase.from('diagrams').delete().eq('owner_id', user.id)
+ async deleteById(id: string): Promise {
+ const { error } = await supabase.from('diagrams').delete().eq('id', id)
if (error) {
- console.error('Failed to clear diagrams from Supabase', error)
+ console.error('Failed to delete diagram from Supabase', error)
}
}
}
diff --git a/src/state/projectStore.ts b/src/state/projectStore.ts
index 8870956..283c846 100644
--- a/src/state/projectStore.ts
+++ b/src/state/projectStore.ts
@@ -3,7 +3,7 @@ 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 type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
// 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.
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 {
+ const list = await repository.list()
+ if (list.length === 0) return null
+ return repository.loadById(list[0].id)
+}
+
interface ProjectStoreState {
project: Project
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
selectedConnectionId: string | null
lastConnectionError: string | null
clearConnectionError: () => void
- loadFromStorage: () => Promise
- newProject: (name?: string) => void
+ /** Figures out which diagram to open on launch (last one you had open, else
+ * most recently updated, else a fresh empty one) and loads it. */
+ loadInitialDiagram: () => Promise
+ refreshDiagramList: () => Promise
+ /** 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
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
+ /** Switches the active diagram to another one of the user's own. */
+ switchToDiagram: (id: string) => Promise
+ /** 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
selectDevice: (id: string | null) => void
selectConnection: (id: string | null) => void
@@ -79,26 +124,62 @@ function touch(project: Project): Project {
export const useProjectStore = create((set, get) => ({
project: createEmptyProject(),
isLoaded: false,
+ diagramList: [],
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
clearConnectionError: () => set({ lastConnectionError: null }),
- loadFromStorage: async () => {
- const loaded = await repository.load()
- set({ project: loaded ?? createEmptyProject(), isLoaded: true })
+ loadInitialDiagram: async () => {
+ const lastId = getLastDiagramId()
+ 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') => {
- set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null })
+ refreshDiagramList: async () => {
+ 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) => {
set((state) => ({ project: touch({ ...state.project, name }) }))
},
- importProject: (project) => {
- set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null })
+ importProject: async (project) => {
+ 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 }),