Wire the app to the Supabase backend, replacing localStorage-only

- 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
This commit is contained in:
2026-09-05 00:33:28 -05:00
co-authored by Claude Sonnet 5
parent 8cea3f8b92
commit 1ae967c8a4
13 changed files with 411 additions and 20 deletions
+18
View File
@@ -0,0 +1,18 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
* know or care which one is active.
*
* Named for the "Diagram" terminology decided in organized-ideas.md §8 (renamed from
* "Project"). This is a deliberately scoped rename: only the storage layer's naming
* changed here. The domain type (`Project`), the Zustand store (`useProjectStore`), and
* user-facing copy still say "Project" — that's a separate, larger mechanical rename
* across the whole app, tracked as its own task rather than bundled into this one.
*/
export interface DiagramRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
@@ -1,10 +1,12 @@
import type { Project } from '../domain/types'
import type { DiagramRepository } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import type { ProjectRepository } from './ProjectRepository'
const STORAGE_KEY = 'av-planner:project:v1'
export class LocalStorageProjectRepository implements ProjectRepository {
/** Kept around (no longer the active repository — see projectStore.ts) as a
* reference implementation and a safe fallback if Supabase is unreachable. */
export class LocalStorageDiagramRepository implements DiagramRepository {
async load(): Promise<Project | null> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
-13
View File
@@ -1,13 +0,0 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. Today only
* `LocalStorageProjectRepository` implements it; a future backend would add
* e.g. `ApiProjectRepository` (REST/GraphQL) behind this same interface so
* the store/UI layers wouldn't need to change.
*/
export interface ProjectRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
+73
View File
@@ -0,0 +1,73 @@
import type { Project } from '../domain/types'
import type { DiagramRepository } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import { supabase } from './supabaseClient'
/**
* Backs the app with the local (or eventually production) Supabase
* `diagrams` table instead of localStorage. The whole `Project` domain
* object is stored as-is in the `data` JSONB column — see the schema
* 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.
*/
export class SupabaseDiagramRepository implements DiagramRepository {
async load(): Promise<Project | null> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return null
const { data: row, error } = await supabase
.from('diagrams')
.select('data')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
console.error('Failed to load diagram from Supabase', error)
return null
}
if (!row) return null
return normalizeProject(row.data as Project)
}
async save(project: Project): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
console.error('Cannot save diagram: not signed in')
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(),
})
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)
if (error) {
console.error('Failed to clear diagrams from Supabase', error)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@supabase/supabase-js'
const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!url || !anonKey) {
throw new Error(
'Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Copy .env.example to .env.local and fill them in ' +
'(run `npx supabase start` then `npx supabase status` for local values).',
)
}
export const supabase = createClient(url, anonKey)