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:
@@ -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<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>
|
||||
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 { 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<string, Project>
|
||||
|
||||
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<Project | null> {
|
||||
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<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> {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
|
||||
const all = readAll()
|
||||
all[project.id] = project
|
||||
writeAll(all)
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
async deleteById(id: string): Promise<void> {
|
||||
const all = readAll()
|
||||
delete all[id]
|
||||
writeAll(all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Project | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return null
|
||||
async list(): Promise<DiagramSummary[]> {
|
||||
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<Project | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user