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 ( + +
+ + + +
+
+ ) +} 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 }),