diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c94e5be --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env.local (gitignored) and fill in. For local dev, run +# `npx supabase status` after `npx supabase start` to get these values — +# they're stable, well-known local dev credentials, not secrets. +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_ANON_KEY= diff --git a/package-lock.json b/package-lock.json index 1484ba9..3d2f3ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "av-planner", "version": "0.0.0", "dependencies": { + "@supabase/supabase-js": "^2.115.0", "@xyflow/react": "^12.11.5", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -714,6 +715,98 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz", + "integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz", + "integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz", + "integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz", + "integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz", + "integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz", + "integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.115.0", + "@supabase/functions-js": "2.115.0", + "@supabase/postgrest-js": "2.115.0", + "@supabase/realtime-js": "2.115.0", + "@supabase/storage-js": "2.115.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1639,6 +1732,15 @@ "dev": true, "license": "ISC" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2158,6 +2260,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index ae241e1..f0c8c6b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.115.0", "@xyflow/react": "^12.11.5", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/App.tsx b/src/App.tsx index 324378e..7c42032 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,28 @@ +import type { Session } from '@supabase/supabase-js' +import { useEffect, useState } from 'react' +import LoginScreen from './components/auth/LoginScreen' import AppShell from './components/layout/AppShell' +import { supabase } from './data/supabaseClient' export default function App() { + // undefined = still checking for an existing session; null = signed out. + const [session, setSession] = useState(undefined) + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => setSession(data.session)) + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => setSession(session)) + return () => subscription.unsubscribe() + }, []) + + if (session === undefined) { + return
Loading…
+ } + + if (!session) { + return + } + return } diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx new file mode 100644 index 0000000..9317dfd --- /dev/null +++ b/src/components/auth/LoginScreen.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { supabase } from '../../data/supabaseClient' + +/** + * Bare-bones email/password auth gate — just enough for a real signed-in + * session to exist so Supabase RLS (which requires auth.uid()) has someone + * to authorize. This is NOT the Phase 2 auth experience from + * organized-ideas.md §2 (Google SSO, polished signup, hard email-verification + * UX) — it's the minimum viable version of the same schema (username + + * email + password) so the backend foundation is actually usable end to end + * before that phase exists. Confirmation email respects the same + * hard-verification-gate config as Phase 2 will (supabase/config.toml). + */ +export default function LoginScreen() { + const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [username, setUsername] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [confirmSent, setConfirmSent] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + if (mode === 'sign-in') { + const { error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) throw error + } else { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { data: { username: username.trim() } }, + }) + if (error) throw error + setConfirmSent(true) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + if (confirmSent) { + return ( +
+
+

Check your email

+

+ We sent a confirmation link to {email}. You'll need to confirm + before you can sign in. +

+

+ Local dev: open Mailpit at{' '} + + 127.0.0.1:54324 + {' '} + to see it — no real email is sent. +

+ +
+
+ ) + } + + return ( +
+
+

AV Planner

+

{mode === 'sign-in' ? 'Sign in' : 'Create an account'}

+ +
+ {mode === 'sign-up' && ( + + )} + + +
+ + {error &&

{error}

} + + + + +
+
+ ) +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index b8f1cd1..c747815 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport' +import { supabase } from '../../data/supabaseClient' import { useProjectStore } from '../../state/projectStore' export default function TopBar() { @@ -58,6 +59,12 @@ export default function TopBar() { > Export + ) diff --git a/src/data/DiagramRepository.ts b/src/data/DiagramRepository.ts new file mode 100644 index 0000000..810437a --- /dev/null +++ b/src/data/DiagramRepository.ts @@ -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 + save(project: Project): Promise + clear(): Promise +} diff --git a/src/data/LocalStorageProjectRepository.ts b/src/data/LocalStorageDiagramRepository.ts similarity index 70% rename from src/data/LocalStorageProjectRepository.ts rename to src/data/LocalStorageDiagramRepository.ts index ddbfb69..be0e6c1 100644 --- a/src/data/LocalStorageProjectRepository.ts +++ b/src/data/LocalStorageDiagramRepository.ts @@ -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 { try { const raw = window.localStorage.getItem(STORAGE_KEY) diff --git a/src/data/ProjectRepository.ts b/src/data/ProjectRepository.ts deleted file mode 100644 index 5ab3f3f..0000000 --- a/src/data/ProjectRepository.ts +++ /dev/null @@ -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 - save(project: Project): Promise - clear(): Promise -} diff --git a/src/data/SupabaseDiagramRepository.ts b/src/data/SupabaseDiagramRepository.ts new file mode 100644 index 0000000..49398cf --- /dev/null +++ b/src/data/SupabaseDiagramRepository.ts @@ -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 { + 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 { + 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 { + 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) + } + } +} diff --git a/src/data/supabaseClient.ts b/src/data/supabaseClient.ts new file mode 100644 index 0000000..d77ac4b --- /dev/null +++ b/src/data/supabaseClient.ts @@ -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) diff --git a/src/state/projectStore.ts b/src/state/projectStore.ts index 8842ea9..8870956 100644 --- a/src/state/projectStore.ts +++ b/src/state/projectStore.ts @@ -3,12 +3,15 @@ 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 { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository' -import type { ProjectRepository } from '../data/ProjectRepository' +import type { DiagramRepository } from '../data/DiagramRepository' +import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository' -// Swapping to a backend later means constructing a different repository here -// (or injecting one) — nothing else in the store changes. -const repository: ProjectRepository = new LocalStorageProjectRepository() +// This is exactly the swap the repository pattern was built for: only this +// one line changed to move off localStorage-only and onto real (locally +// self-hosted, for now) Supabase persistence — nothing else in the store or +// UI layers needed to change. LocalStorageDiagramRepository is still in the +// codebase as a reference implementation / fallback, just no longer active. +const repository: DiagramRepository = new SupabaseDiagramRepository() interface ProjectStoreState { project: Project diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..377b9ec --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL: string + readonly VITE_SUPABASE_ANON_KEY: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}