From 511b2fb6c206f9170c450011b0a373eb0c295bac Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Thu, 9 Apr 2026 19:56:44 -0400 Subject: [PATCH 1/7] New login page No google or apple yet --- frontend/src/App.css | 340 +++++++++++++++++++++++++++++-- frontend/src/pages/LoginPage.tsx | 244 ++++++++++++++++++---- 2 files changed, 531 insertions(+), 53 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index cbe4e2b..be3ee50 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -179,22 +179,338 @@ body.layout-editor-open .main.main-wide { font: inherit; } -.auth-page { - max-width: 400px; - margin: 48px auto; - padding: 0 16px; - text-align: left; +/* —— Auth / login (full-viewport marketing-style shell) —— */ +.auth-shell { + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: max(20px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); + background: + radial-gradient(120% 80% at 50% -10%, rgba(16, 185, 129, 0.09), transparent 52%), + radial-gradient(90% 60% at 100% 50%, rgba(99, 102, 241, 0.06), transparent 45%), + linear-gradient(180deg, #0c0c0f 0%, #0b0b0b 55%, #09090b 100%); } -.auth-page h1 { - margin-top: 0; +.auth-card { + width: 100%; + max-width: 420px; + padding: 32px 28px 28px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(165deg, rgba(28, 28, 32, 0.94) 0%, rgba(18, 18, 22, 0.98) 100%); + box-shadow: + var(--shadow), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +@media (max-width: 480px) { + .auth-card { + padding: 28px 20px 24px; + border-radius: 14px; + } +} + +.auth-card-header { + text-align: center; + margin-bottom: 24px; } -.auth-page-brand { - margin: 0 0 4px; +.auth-card-brand { + display: flex; + justify-content: center; + margin-bottom: 18px; line-height: 0; } +.auth-card-title { + margin: 0 0 8px; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--text-h); + line-height: 1.2; +} + +@media (max-width: 480px) { + .auth-card-title { + font-size: 1.35rem; + } +} + +.auth-card-sub { + margin: 0; + font-size: 14px; + line-height: 1.45; + color: var(--text); + opacity: 0.95; +} + +.auth-social { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-social-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 11px 16px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font: inherit; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.01em; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + transform 0.08s ease; +} + +.auth-social-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.14); +} + +.auth-social-btn:active { + transform: scale(0.99); +} + +.auth-social-btn:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.55); + outline-offset: 2px; +} + +.auth-social-btn--google { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.97); + color: #1f1f1f; +} + +.auth-social-btn--google:hover { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.12); +} + +.auth-social-btn--apple { + background: #000000; + border-color: rgba(255, 255, 255, 0.2); + color: #fafafa; +} + +.auth-social-btn--apple:hover { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.28); +} + +.auth-social-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.auth-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 22px 0 20px; +} + +.auth-divider-line { + flex: 1; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent); +} + +.auth-divider-text { + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text); + opacity: 0.75; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-banner { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; +} + +.auth-banner--error { + color: #fecaca; + background: rgba(185, 28, 28, 0.2); + border: 1px solid rgba(248, 113, 113, 0.35); +} + +.auth-banner--info { + color: #a7f3d0; + background: var(--accent-bg); + border: 1px solid var(--accent-border); +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + font-size: 14px; +} + +.auth-label, +.auth-label-row .auth-label { + font-size: 13px; + font-weight: 500; + color: var(--text-h); + letter-spacing: 0.01em; +} + +.auth-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.auth-inline-link { + flex-shrink: 0; + padding: 0; + border: none; + background: none; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--accent); + cursor: pointer; + text-decoration: none; +} + +.auth-inline-link:hover { + text-decoration: underline; +} + +.auth-inline-link:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.5); + outline-offset: 2px; + border-radius: 4px; +} + +.auth-input { + width: 100%; + box-sizing: border-box; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 10px; + font: inherit; + font-size: 15px; + background: rgba(10, 10, 12, 0.65); + color: var(--text-h); + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.auth-input:hover { + border-color: rgba(255, 255, 255, 0.14); +} + +.auth-input:focus { + outline: none; + border-color: var(--accent-border); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18); +} + +.auth-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 12px 18px; + border-radius: 10px; + border: 1px solid rgba(16, 185, 129, 0.55); + background: linear-gradient(180deg, rgba(16, 185, 129, 0.35) 0%, rgba(5, 150, 105, 0.45) 100%); + color: #ecfdf5; + font: inherit; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset; + transition: + filter 0.15s ease, + opacity 0.15s ease; +} + +.auth-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.auth-primary:disabled { + opacity: 0.75; + cursor: wait; +} + +.auth-primary:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.65); + outline-offset: 2px; +} + +.auth-primary-spinner { + width: 18px; + height: 18px; + animation: layout-save-spin 0.75s linear infinite; +} + +.auth-footer { + margin: 24px 0 0; + text-align: center; + font-size: 14px; + color: var(--text); + line-height: 1.5; +} + +.auth-footer-link { + padding: 0; + border: none; + background: none; + font: inherit; + font-weight: 600; + color: var(--accent); + cursor: pointer; +} + +.auth-footer-link:hover { + text-decoration: underline; +} + +.auth-footer-link:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.5); + outline-offset: 2px; + border-radius: 4px; +} + .card { display: flex; flex-direction: column; @@ -239,8 +555,8 @@ label.layout-editor-footer-value-strip { font-size: 11px; } -input[type='text']:not(.layout-editor-footer-value-input), -input[type='password'], +input[type='text']:not(.layout-editor-footer-value-input):not(.auth-input), +input[type='password']:not(.auth-input), input[type='number']:not(.layout-editor-footer-value-input), input[type='file'], select, @@ -258,7 +574,7 @@ textarea { min-height: 80px; } -button[type='submit'], +button[type='submit']:not(.auth-primary), .page button[type='button']:not(.link-btn):not(.link-danger):not(.zone-tool):not(.zone-row-body):not( .layout-fullscreen-ghost-btn diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 37535a8..9e56e1f 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,23 +1,66 @@ -import { useState, type FormEvent } from 'react'; +import { useId, useState, type FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; import { BrandLogo } from '../components/BrandLogo'; import { useAuth } from '../contexts/useAuth'; +function GoogleGlyph({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function AppleGlyph({ className }: { className?: string }) { + return ( + + + + ); +} + export function LoginPage() { const { user, login, register } = useAuth(); const [mode, setMode] = useState<'login' | 'register'>('login'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); + const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); + const errorId = useId(); + const infoId = useId(); if (user) { return ; } + function clearMessages() { + setError(null); + setInfo(null); + } + async function onSubmit(e: FormEvent) { e.preventDefault(); - setError(null); + clearMessages(); setBusy(true); try { if (mode === 'login') await login(username, password); @@ -29,55 +72,174 @@ export function LoginPage() { } } + function onSocialPlaceholder(provider: 'google' | 'apple') { + clearMessages(); + setInfo( + provider === 'google' + ? 'Google sign-in is not enabled for this deployment yet. Use email and password below.' + : 'Apple sign-in is not enabled for this deployment yet. Use email and password below.' + ); + } + + const heading = mode === 'login' ? 'Welcome back' : 'Create your account'; + const sub = + mode === 'login' + ? 'Sign in to CardGoose to continue to your workspace.' + : 'Set up your credentials to start using CardGoose.'; + return ( -
-

- -

-

MVP test harness — sign in

-
-
+
+
+
+
+ +
+

{heading}

+

{sub}

+
+ +
- - - {error &&

{error}

} - - + +
+ + or + +
+ +
+ {info && ( +

+ {info} +

+ )} + {error && ( + + )} + + + + + + +
+ +

+ {mode === 'login' ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +

+
); } From e4adafd60d822db055f357e7d58036e2314d655f Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Thu, 9 Apr 2026 20:12:24 -0400 Subject: [PATCH 2/7] Google auth signin --- .env.local.example | 3 + .../migration.sql | 2 + api/prisma/schema.prisma | 4 +- api/src/routes/auth.ts | 108 ++++++++++++++--- frontend/.env.local.example | 10 +- frontend/Dockerfile | 3 + frontend/src/App.css | 12 ++ frontend/src/contexts/AuthProvider.tsx | 23 +++- frontend/src/contexts/auth-context-value.ts | 5 +- frontend/src/lib/loadGsiScript.ts | 51 ++++++++ frontend/src/pages/LoginPage.tsx | 111 ++++++++++++++---- frontend/src/types/google-gsi.d.ts | 27 +++++ frontend/src/vite-env.d.ts | 2 + 13 files changed, 312 insertions(+), 49 deletions(-) create mode 100644 api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql create mode 100644 frontend/src/lib/loadGsiScript.ts create mode 100644 frontend/src/types/google-gsi.d.ts diff --git a/.env.local.example b/.env.local.example index 8bff4ed..369b3ae 100644 --- a/.env.local.example +++ b/.env.local.example @@ -31,3 +31,6 @@ JWT_SECRET=change-me-local-dev-only RENDER_URL=http://localhost:5173 # VITE_API_URL= # leave unset; Vite proxies to local API +# +# Frontend (put in frontend/.env.local — Vite reads that folder when you run `pnpm dev:frontend`): +# VITE_GOOGLE_CLIENT_ID=your-web-client-id.apps.googleusercontent.com diff --git a/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql b/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql new file mode 100644 index 0000000..5c92140 --- /dev/null +++ b/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: allow OAuth users without a password +ALTER TABLE "User" ALTER COLUMN "password_hash" DROP NOT NULL; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 183192f..a149230 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -7,11 +7,11 @@ datasource db { url = env("DATABASE_URL") } -/// Auth: username + password (hashed); JWT issued separately. +/// Auth: unique `username` stores normalized email; password optional for Google-only accounts. model User { id String @id @default(uuid()) @db.Uuid username String @unique - passwordHash String @map("password_hash") + passwordHash String? @map("password_hash") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index a545139..f78cd82 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -5,26 +5,48 @@ import { signToken } from '../lib/jwt.js'; export const authRouter: IRouter = Router(); +const GOOGLE_USERINFO = 'https://www.googleapis.com/oauth2/v3/userinfo'; + +function normalizeEmail(input: string): string { + return input.trim().toLowerCase(); +} + +function isValidEmail(s: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); +} + authRouter.post('/register', async (req, res) => { - const { username, password } = req.body as { username?: string; password?: string }; - if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { - res.status(400).json({ error: 'username and password are required' }); + const body = req.body as { email?: string; username?: string; password?: string }; + const raw = body.email ?? body.username; + const password = body.password; + if (!raw || !password || typeof raw !== 'string' || typeof password !== 'string') { + res.status(400).json({ error: 'email and password are required' }); return; } - if (username.length < 2 || password.length < 6) { - res.status(400).json({ error: 'username min 2 chars, password min 6 chars' }); + + const email = normalizeEmail(raw); + if (!isValidEmail(email)) { + res.status(400).json({ error: 'Enter a valid email address' }); + return; + } + if (password.length < 6) { + res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } - const existing = await prisma.user.findUnique({ where: { username } }); + const existing = await prisma.user.findUnique({ where: { username: email } }); if (existing) { - res.status(409).json({ error: 'Username already taken' }); + if (!existing.passwordHash) { + res.status(409).json({ error: 'An account with this email already exists. Sign in with Google.' }); + return; + } + res.status(409).json({ error: 'An account with this email already exists' }); return; } const passwordHash = await bcrypt.hash(password, 10); const user = await prisma.user.create({ - data: { username, passwordHash }, + data: { username: email, passwordHash }, }); const token = signToken({ sub: user.id, username: user.username }); @@ -36,21 +58,29 @@ authRouter.post('/register', async (req, res) => { }); authRouter.post('/login', async (req, res) => { - const { username, password } = req.body as { username?: string; password?: string }; - if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { - res.status(400).json({ error: 'username and password are required' }); + const body = req.body as { email?: string; username?: string; password?: string }; + const raw = body.email ?? body.username; + const password = body.password; + if (!raw || !password || typeof raw !== 'string' || typeof password !== 'string') { + res.status(400).json({ error: 'email and password are required' }); return; } - const user = await prisma.user.findUnique({ where: { username } }); + const email = normalizeEmail(raw); + const user = await prisma.user.findUnique({ where: { username: email } }); if (!user) { - res.status(401).json({ error: 'Invalid credentials' }); + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + if (!user.passwordHash) { + res.status(401).json({ error: 'This account uses Google sign-in' }); return; } const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) { - res.status(401).json({ error: 'Invalid credentials' }); + res.status(401).json({ error: 'Invalid email or password' }); return; } @@ -61,3 +91,53 @@ authRouter.post('/login', async (req, res) => { user: { id: user.id, username: user.username }, }); }); + +authRouter.post('/google', async (req, res) => { + const { accessToken } = req.body as { accessToken?: string }; + if (!accessToken || typeof accessToken !== 'string') { + res.status(400).json({ error: 'accessToken is required' }); + return; + } + + const gr = await fetch(GOOGLE_USERINFO, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!gr.ok) { + req.log.warn({ status: gr.status }, 'auth.google userinfo failed'); + res.status(401).json({ error: 'Invalid or expired Google session' }); + return; + } + + const profile = (await gr.json()) as { + sub?: string; + email?: string; + email_verified?: boolean; + }; + + if (!profile.email || profile.email_verified !== true) { + res.status(400).json({ error: 'Google did not return a verified email' }); + return; + } + + const email = normalizeEmail(profile.email); + if (!isValidEmail(email)) { + res.status(400).json({ error: 'Invalid email from Google' }); + return; + } + + let user = await prisma.user.findUnique({ where: { username: email } }); + if (!user) { + user = await prisma.user.create({ + data: { username: email, passwordHash: null }, + }); + req.log.info({ userId: user.id, username: user.username }, 'auth.google register ok'); + } else { + req.log.info({ userId: user.id, username: user.username }, 'auth.google login ok'); + } + + const token = signToken({ sub: user.id, username: user.username }); + res.json({ + token, + user: { id: user.id, username: user.username }, + }); +}); diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 8e5a077..fa8783c 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -7,7 +7,13 @@ # Services → cardboardforge-prod-api → Tasks (running) → click task → # "Public IP" under Configuration. # -# No trailing slash. Example: +# No trailing slash. Example (remote API only — not needed for `pnpm dev:local`): # VITE_API_URL=http://54.123.45.67:3001 +# +# For local dev, omit VITE_API_URL entirely so requests stay same-origin and Vite proxies `/api` → localhost:3001. +# A placeholder hostname here will break auth (ERR_NAME_NOT_RESOLVED). -VITE_API_URL=http://YOUR_ECS_TASK_PUBLIC_IP:3001 +# Google Sign-In (OAuth 2.0 Client ID, type “Web application”). Required for “Continue with Google”. +# In Google Cloud Console → APIs & Services → Credentials: add Authorized JavaScript origins +# (e.g. http://localhost:5173 and your production site origin). Optional locally if you only use email/password. +# VITE_GOOGLE_CLIENT_ID=123456789-xxxxxxxx.apps.googleusercontent.com diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f6ef961..da8d8c7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,6 +9,9 @@ COPY frontend/package.json frontend/ RUN pnpm install --frozen-lockfile COPY frontend ./frontend WORKDIR /app/frontend +# Bake OAuth Web client ID into the SPA at build time (same value as local VITE_GOOGLE_CLIENT_ID). +ARG VITE_GOOGLE_CLIENT_ID= +ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID RUN pnpm run build FROM nginx:1.27-alpine AS production diff --git a/frontend/src/App.css b/frontend/src/App.css index be3ee50..894d874 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -320,6 +320,18 @@ body.layout-editor-open .main.main-wide { flex-shrink: 0; } +.auth-social-icon--apple { + width: 15px; + height: 19px; +} + +.auth-social-spinner { + width: 18px; + height: 18px; + flex-shrink: 0; + animation: layout-save-spin 0.75s linear infinite; +} + .auth-divider { display: flex; align-items: center; diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx index 2bd6c0a..cac8921 100644 --- a/frontend/src/contexts/AuthProvider.tsx +++ b/frontend/src/contexts/AuthProvider.tsx @@ -16,10 +16,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const login = useCallback( - async (username: string, password: string) => { + async (email: string, password: string) => { const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/login', { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), }); persist(data.token, data.user); }, @@ -27,10 +27,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const register = useCallback( - async (username: string, password: string) => { + async (email: string, password: string) => { const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/register', { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), + }); + persist(data.token, data.user); + }, + [persist] + ); + + const loginWithGoogle = useCallback( + async (accessToken: string) => { + const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/google', { + method: 'POST', + body: JSON.stringify({ accessToken }), }); persist(data.token, data.user); }, @@ -43,8 +54,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const value = useMemo( - () => ({ token, user, login, register, logout }), - [token, user, login, register, logout] + () => ({ token, user, login, register, loginWithGoogle, logout }), + [token, user, login, register, loginWithGoogle, logout] ); return {children}; diff --git a/frontend/src/contexts/auth-context-value.ts b/frontend/src/contexts/auth-context-value.ts index 508c456..19c73c5 100644 --- a/frontend/src/contexts/auth-context-value.ts +++ b/frontend/src/contexts/auth-context-value.ts @@ -3,7 +3,8 @@ import type { AuthUser } from './auth-types'; export type AuthContextValue = { token: string | null; user: AuthUser | null; - login: (username: string, password: string) => Promise; - register: (username: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + loginWithGoogle: (accessToken: string) => Promise; logout: () => void; }; diff --git a/frontend/src/lib/loadGsiScript.ts b/frontend/src/lib/loadGsiScript.ts new file mode 100644 index 0000000..17e1e6f --- /dev/null +++ b/frontend/src/lib/loadGsiScript.ts @@ -0,0 +1,51 @@ +const GSI_SRC = 'https://accounts.google.com/gsi/client'; + +let injectPromise: Promise | null = null; + +/** Injects `gsi/client` once and resolves when the script has loaded. */ +function ensureGsiScript(): Promise { + if (typeof window === 'undefined') return Promise.resolve(); + if (window.google?.accounts?.oauth2) return Promise.resolve(); + + if (injectPromise) return injectPromise; + + if (document.querySelector(`script[src="${GSI_SRC}"]`)) { + // Tag already present (e.g. cached); `load` will not fire again — polling below waits for `oauth2`. + injectPromise = Promise.resolve(); + return injectPromise; + } + + injectPromise = new Promise((resolve, reject) => { + const el = document.createElement('script'); + el.src = GSI_SRC; + el.async = true; + el.onload = () => resolve(); + el.onerror = () => { + injectPromise = null; + reject(new Error('Failed to load Google Sign-In script')); + }; + document.head.appendChild(el); + }); + + return injectPromise; +} + +/** Loads the Google Identity Services client once; safe to call in parallel. */ +export async function loadGsiScript(): Promise { + if (typeof window === 'undefined') return; + if (window.google?.accounts?.oauth2) return; + + await ensureGsiScript(); + + // Microtask-only loops starve the browser: the external script cannot run. Poll with `setTimeout` + // so the GSI bundle can execute and attach `google.accounts.oauth2`. + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (window.google?.accounts?.oauth2) return; + await new Promise((r) => { + setTimeout(r, 50); + }); + } + + throw new Error('Google Sign-In did not initialize'); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 9e56e1f..4c4c27f 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -2,6 +2,7 @@ import { useId, useState, type FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; import { Loader2 } from 'lucide-react'; import { BrandLogo } from '../components/BrandLogo'; +import { loadGsiScript } from '../lib/loadGsiScript'; import { useAuth } from '../contexts/useAuth'; function GoogleGlyph({ className }: { className?: string }) { @@ -27,25 +28,37 @@ function GoogleGlyph({ className }: { className?: string }) { ); } +/** Apple mark (Font Awesome–style path); viewBox matches path coordinates. */ function AppleGlyph({ className }: { className?: string }) { return ( - + ); } +function shouldIgnoreGoogleUiError(code: string): boolean { + const c = code.toLowerCase(); + return c.includes('popup') || c.includes('cancel') || c === 'access_denied'; +} + export function LoginPage() { - const { user, login, register } = useAuth(); + const { user, login, register, loginWithGoogle } = useAuth(); const [mode, setMode] = useState<'login' | 'register'>('login'); - const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); + const [googleBusy, setGoogleBusy] = useState(false); const errorId = useId(); const infoId = useId(); @@ -63,8 +76,8 @@ export function LoginPage() { clearMessages(); setBusy(true); try { - if (mode === 'login') await login(username, password); - else await register(username, password); + if (mode === 'login') await login(email, password); + else await register(email, password); } catch (err) { setError(err instanceof Error ? err.message : 'Request failed'); } finally { @@ -72,13 +85,57 @@ export function LoginPage() { } } - function onSocialPlaceholder(provider: 'google' | 'apple') { + async function onGoogleClick() { + clearMessages(); + const cid = import.meta.env.VITE_GOOGLE_CLIENT_ID?.trim(); + if (!cid) { + setError( + 'Google sign-in is not configured. Set VITE_GOOGLE_CLIENT_ID in your environment (OAuth 2.0 Web client ID).' + ); + return; + } + + setGoogleBusy(true); + try { + await loadGsiScript(); + if (!window.google?.accounts?.oauth2) { + throw new Error('Google Sign-In did not initialize'); + } + + const client = window.google.accounts.oauth2.initTokenClient({ + client_id: cid, + scope: 'openid email profile', + callback: (tokenResponse) => { + void (async () => { + try { + if (tokenResponse.error) { + if (!shouldIgnoreGoogleUiError(tokenResponse.error)) { + setError( + tokenResponse.error_description ?? tokenResponse.error ?? 'Google sign-in failed' + ); + } + return; + } + if (!tokenResponse.access_token) return; + await loginWithGoogle(tokenResponse.access_token); + } catch (err) { + setError(err instanceof Error ? err.message : 'Google sign-in failed'); + } finally { + setGoogleBusy(false); + } + })(); + }, + }); + client.requestAccessToken({ prompt: '' }); + } catch (err) { + setGoogleBusy(false); + setError(err instanceof Error ? err.message : 'Could not start Google sign-in'); + } + } + + function onApplePlaceholder() { clearMessages(); - setInfo( - provider === 'google' - ? 'Google sign-in is not enabled for this deployment yet. Use email and password below.' - : 'Apple sign-in is not enabled for this deployment yet. Use email and password below.' - ); + setInfo('Apple sign-in is not enabled for this deployment yet. Use email and password or Google.'); } const heading = mode === 'login' ? 'Welcome back' : 'Create your account'; @@ -102,17 +159,24 @@ export function LoginPage() { Continue with Google + {googleBusy ? ( + + ) : ( + + )} + {googleBusy ? 'Connecting…' : 'Continue with Google'}
@@ -141,18 +205,19 @@ export function LoginPage() { )}
From 2cba1e811d537e2f32ec3de6f38d72cc578ba676 Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Thu, 9 Apr 2026 20:28:27 -0400 Subject: [PATCH 4/7] Data setting form and toasts --- frontend/src/App.css | 98 ++++++++++-- frontend/src/components/CardGroupsPanel.tsx | 164 +++++++++++--------- frontend/src/contexts/ToastProvider.tsx | 71 +++++++++ frontend/src/contexts/toast-context.ts | 8 + frontend/src/contexts/useToast.ts | 10 ++ frontend/src/main.tsx | 9 +- frontend/src/pages/DashboardPage.tsx | 15 +- frontend/src/pages/LoginPage.tsx | 25 +-- frontend/src/pages/ProjectPage.tsx | 51 +++--- 9 files changed, 302 insertions(+), 149 deletions(-) create mode 100644 frontend/src/contexts/ToastProvider.tsx create mode 100644 frontend/src/contexts/toast-context.ts create mode 100644 frontend/src/contexts/useToast.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 4f85d4b..cf45105 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1727,23 +1727,6 @@ button.zone-row-body:hover { max-width: 160px; } -.card-group-data-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); - color: #d4d4d8; - font-size: 12px; - font-weight: 500; -} - -.card-group-data-pill--muted { - color: rgba(161, 161, 170, 0.9); -} - .card-group-synced { font-size: 11px; color: rgba(161, 161, 170, 0.85); @@ -1801,6 +1784,17 @@ button.zone-row-body:hover { background: rgba(0, 0, 0, 0.2); } +.card-group-url-drawer--popover { + border-bottom: none; + background: transparent; + padding: 8px 10px 10px; +} + +.card-group-menu--data-source { + min-width: min(100vw - 32px, 360px); + max-width: min(100vw - 24px, 420px); +} + .card-group-url-drawer-label { display: flex; flex-direction: column; @@ -2856,3 +2850,73 @@ button.zone-tool--icon { button.zone-tool--icon:hover:not(:disabled) { color: var(--accent); } + +/* —— Global toasts (bottom of viewport) —— */ +.toast-viewport { + position: fixed; + bottom: max(16px, env(safe-area-inset-bottom, 0px)); + left: 16px; + right: 16px; + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + max-width: 420px; + padding: 12px 12px 12px 14px; + border-radius: 10px; + font-size: 14px; + line-height: 1.4; + box-shadow: var(--shadow); + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(22, 22, 26, 0.97); + backdrop-filter: blur(10px); + color: var(--text-h); +} + +.toast--error { + border-color: rgba(248, 113, 113, 0.45); + background: rgba(69, 10, 10, 0.92); + color: #fecaca; +} + +.toast--info { + border-color: var(--accent-border); + background: rgba(16, 24, 22, 0.96); + color: #d1fae5; +} + +.toast-message { + flex: 1; + min-width: 0; + word-break: break-word; +} + +.toast-dismiss { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + margin: -4px -4px -4px 0; + padding: 4px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + opacity: 0.65; + cursor: pointer; +} + +.toast-dismiss:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); +} diff --git a/frontend/src/components/CardGroupsPanel.tsx b/frontend/src/components/CardGroupsPanel.tsx index 6171bf4..764675b 100644 --- a/frontend/src/components/CardGroupsPanel.tsx +++ b/frontend/src/components/CardGroupsPanel.tsx @@ -110,7 +110,8 @@ export function CardGroupsPanel(props: { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [editingTitleId, setEditingTitleId] = useState(null); - const [urlEditorGroupId, setUrlEditorGroupId] = useState(null); + /** Which card group has the data source URL popover open (same pattern as layout dropdown). */ + const [dataSourceMenuGroupId, setDataSourceMenuGroupId] = useState(null); const [urlDraft, setUrlDraft] = useState(''); /** Group ids whose gallery is collapsed (default: expanded) */ const [collapsedGalleryIds, setCollapsedGalleryIds] = useState>(() => new Set()); @@ -194,14 +195,14 @@ export function CardGroupsPanel(props: { token, }); setGroups((prev) => prev.filter((g) => g.id !== groupId)); - if (urlEditorGroupId === groupId) setUrlEditorGroupId(null); + if (dataSourceMenuGroupId === groupId) setDataSourceMenuGroupId(null); } catch (e) { onError(e instanceof Error ? e.message : 'Delete failed'); } finally { onBusy(false); } }, - [token, projectId, onBusy, onError, urlEditorGroupId] + [token, projectId, onBusy, onError, dataSourceMenuGroupId] ); const duplicateGroup = useCallback( @@ -244,20 +245,20 @@ export function CardGroupsPanel(props: { [token, projectId, onBusy, onError] ); - const openUrlEditor = useCallback((g: CardGroupDto) => { - setUrlEditorGroupId(g.id); - setUrlDraft(g.csvSourceUrl ?? ''); - }, []); - - const applyUrlEditor = useCallback( + const saveDataSourceUrl = useCallback( async (groupId: string) => { const trimmed = urlDraft.trim(); await updateGroup(groupId, { csvSourceUrl: trimmed || null }); - setUrlEditorGroupId(null); + setDataSourceMenuGroupId(null); }, [urlDraft, updateGroup] ); + const cancelDataSourceUrl = useCallback(() => { + setDataSourceMenuGroupId(null); + void loadGroups(); + }, [loadGroups]); + const toggleGallery = useCallback((groupId: string) => { setCollapsedGalleryIds((prev) => { const next = new Set(prev); @@ -433,13 +434,88 @@ export function CardGroupsPanel(props: { - { + if (open) { + setDataSourceMenuGroupId(g.id); + setUrlDraft(g.csvSourceUrl ?? ''); + } else { + setDataSourceMenuGroupId((prev) => (prev === g.id ? null : prev)); + } + }} > - - {dataLabel} - + + + {dataLabel} + + e.preventDefault()} + > +
e.stopPropagation()} + > + + {projectCsvSourceUrl ? ( + + ) : null} +
+ + +
+
+
+
{ready && g.updatedAt ? ( @@ -472,13 +548,6 @@ export function CardGroupsPanel(props: { > Refresh data - { - setTimeout(() => openUrlEditor(g), 0); - }} - > - Edit data source / URL - void duplicateGroup(g.id)}> Duplicate group @@ -495,55 +564,6 @@ export function CardGroupsPanel(props: {
- {urlEditorGroupId === g.id && ( -
- - {projectCsvSourceUrl ? ( - - ) : null} -
- - -
-
- )} -
([]); + const idRef = useRef(0); + const timersRef = useRef>>(new Map()); + + const remove = useCallback((toastId: number) => { + const t = timersRef.current.get(toastId); + if (t !== undefined) { + window.clearTimeout(t); + timersRef.current.delete(toastId); + } + setToasts((prev) => prev.filter((x) => x.id !== toastId)); + }, []); + + const push = useCallback( + (message: string, variant: 'error' | 'info') => { + const id = ++idRef.current; + setToasts((prev) => [...prev, { id, message, variant }]); + const tid = window.setTimeout(() => remove(id), TOAST_MS); + timersRef.current.set(id, tid); + }, + [remove] + ); + + const showError = useCallback((message: string) => push(message, 'error'), [push]); + const showInfo = useCallback((message: string) => push(message, 'info'), [push]); + + // Stable reference so toast list updates don’t re-render every useToast() consumer. + const value = useMemo( + () => ({ showError, showInfo }), + [showError, showInfo] + ); + + return ( + + {children} + {createPortal( +
+ {toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
, + document.body + )} +
+ ); +} diff --git a/frontend/src/contexts/toast-context.ts b/frontend/src/contexts/toast-context.ts new file mode 100644 index 0000000..1f8bcc2 --- /dev/null +++ b/frontend/src/contexts/toast-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +export type ToastContextValue = { + showError: (message: string) => void; + showInfo: (message: string) => void; +}; + +export const ToastContext = createContext(null); diff --git a/frontend/src/contexts/useToast.ts b/frontend/src/contexts/useToast.ts new file mode 100644 index 0000000..fa94576 --- /dev/null +++ b/frontend/src/contexts/useToast.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ToastContext, type ToastContextValue } from './toast-context'; + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToast must be used within ToastProvider'); + } + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b9e6db1..8d07307 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,13 +4,16 @@ import { BrowserRouter } from 'react-router-dom'; import './index.css'; import App from './App.tsx'; import { AuthProvider } from './contexts/AuthProvider.tsx'; +import { ToastProvider } from './contexts/ToastProvider.tsx'; createRoot(document.getElementById('root')!).render( - - - + + + + + ); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 495a732..1748d96 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -2,32 +2,31 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { Link } from 'react-router-dom'; import { apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; type Project = { id: string; name: string; createdAt: string; updatedAt: string }; export function DashboardPage() { const { token } = useAuth(); + const { showError } = useToast(); const [projects, setProjects] = useState([]); const [name, setName] = useState(''); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback(async () => { if (!token) return; - setError(null); const data = await apiJson<{ projects: Project[] }>('/api/projects', { token }); setProjects(data.projects); }, [token]); useEffect(() => { - void load().catch((e) => setError(e instanceof Error ? e.message : 'Failed to load')); - }, [load]); + void load().catch((e) => showError(e instanceof Error ? e.message : 'Failed to load')); + }, [load, showError]); async function onCreate(e: FormEvent) { e.preventDefault(); if (!token || !name.trim()) return; setBusy(true); - setError(null); try { await apiJson('/api/projects', { method: 'POST', @@ -37,7 +36,7 @@ export function DashboardPage() { setName(''); await load(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed'); + showError(err instanceof Error ? err.message : 'Failed'); } finally { setBusy(false); } @@ -45,12 +44,11 @@ export function DashboardPage() { async function remove(id: string) { if (!token || !confirm('Delete this project?')) return; - setError(null); try { await apiJson(`/api/projects/${id}`, { method: 'DELETE', token }); await load(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed'); + showError(err instanceof Error ? err.message : 'Failed'); } } @@ -70,7 +68,6 @@ export function DashboardPage() { - {error &&

{error}

}
    {projects.map((p) => (
  • diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 4fdaf91..be58a58 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -4,6 +4,7 @@ import { Loader2 } from 'lucide-react'; import { BrandLogo } from '../components/BrandLogo'; import { loadGsiScript } from '../lib/loadGsiScript'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; function GoogleGlyph({ className }: { className?: string }) { return ( @@ -35,14 +36,13 @@ function shouldIgnoreGoogleUiError(code: string): boolean { export function LoginPage() { const { user, login, register, loginWithGoogle } = useAuth(); + const { showError } = useToast(); const [mode, setMode] = useState<'login' | 'register'>('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); const [googleBusy, setGoogleBusy] = useState(false); - const errorId = useId(); const infoId = useId(); if (user) { @@ -50,7 +50,6 @@ export function LoginPage() { } function clearMessages() { - setError(null); setInfo(null); } @@ -62,7 +61,7 @@ export function LoginPage() { if (mode === 'login') await login(email, password); else await register(email, password); } catch (err) { - setError(err instanceof Error ? err.message : 'Request failed'); + showError(err instanceof Error ? err.message : 'Request failed'); } finally { setBusy(false); } @@ -72,7 +71,7 @@ export function LoginPage() { clearMessages(); const cid = import.meta.env.VITE_GOOGLE_CLIENT_ID?.trim(); if (!cid) { - setError( + showError( 'Google sign-in is not configured. Set VITE_GOOGLE_CLIENT_ID in your environment (OAuth 2.0 Web client ID).' ); return; @@ -93,7 +92,7 @@ export function LoginPage() { try { if (tokenResponse.error) { if (!shouldIgnoreGoogleUiError(tokenResponse.error)) { - setError( + showError( tokenResponse.error_description ?? tokenResponse.error ?? 'Google sign-in failed' ); } @@ -102,7 +101,7 @@ export function LoginPage() { if (!tokenResponse.access_token) return; await loginWithGoogle(tokenResponse.access_token); } catch (err) { - setError(err instanceof Error ? err.message : 'Google sign-in failed'); + showError(err instanceof Error ? err.message : 'Google sign-in failed'); } finally { setGoogleBusy(false); } @@ -112,7 +111,7 @@ export function LoginPage() { client.requestAccessToken({ prompt: '' }); } catch (err) { setGoogleBusy(false); - setError(err instanceof Error ? err.message : 'Could not start Google sign-in'); + showError(err instanceof Error ? err.message : 'Could not start Google sign-in'); } } @@ -167,11 +166,6 @@ export function LoginPage() { {info}

    )} - {error && ( - - )} @@ -214,7 +206,6 @@ export function LoginPage() { value={password} onChange={(e) => { setPassword(e.target.value); - if (error) setError(null); if (info) setInfo(null); }} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index fa4f8e7..74a46a4 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } fro import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { apiBase, apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; import { parseCsvText } from '../lib/csv'; import { CardGroupsPanel } from '../components/CardGroupsPanel'; import { LayoutsListPanel } from '../components/LayoutsListPanel'; @@ -38,6 +39,7 @@ export function ProjectPage() { const [searchParams, setSearchParams] = useSearchParams(); const { setLayoutEditorChrome, setProjectViewNav } = useStudioChrome(); const { token } = useAuth(); + const { showError } = useToast(); const [tab, setTab] = useState('cards'); const layoutEditorRef = useRef(null); /** Refresh exports list while a queued PDF may still be processing */ @@ -56,7 +58,6 @@ export function ProjectPage() { const [file, setFile] = useState(null); const [csvFile, setCsvFile] = useState(null); const [csvUrlDraft, setCsvUrlDraft] = useState(''); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); /** Pipeline tab: PDF export bypasses SQS and can take a while */ const [exportPdfLoading, setExportPdfLoading] = useState(false); @@ -118,7 +119,6 @@ export function ProjectPage() { const loadCore = useCallback(async () => { if (!token || !id) return; - setError(null); const [proj, lays] = await Promise.all([ apiJson<{ project: ProjectDetail }>(`/api/projects/${id}`, { token }), apiJson<{ layouts: LayoutFull[] }>(`/api/projects/${id}/layouts`, { token }), @@ -146,8 +146,8 @@ export function ProjectPage() { }, [token, id, loadPipeline, loadCardGroups]); useEffect(() => { - void loadCore().catch((err) => setError(err instanceof Error ? err.message : 'Load failed')); - }, [loadCore]); + void loadCore().catch((err) => showError(err instanceof Error ? err.message : 'Load failed')); + }, [loadCore, showError]); useEffect(() => { if (tab === 'layout') { @@ -160,7 +160,6 @@ export function ProjectPage() { const saveLayout = useCallback(async (): Promise => { if (!token || !id || !activeLayoutId) return false; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>( `/api/projects/${id}/layouts/${activeLayoutId}`, @@ -187,12 +186,12 @@ export function ProjectPage() { setLastSavedAt(new Date()); return true; } catch (err) { - setError(err instanceof Error ? err.message : 'Save failed'); + showError(err instanceof Error ? err.message : 'Save failed'); return false; } finally { setBusy(false); } - }, [token, id, activeLayoutId, layoutName, editorState, project]); + }, [token, id, activeLayoutId, layoutName, editorState, project, showError]); const layoutIsDirty = useMemo(() => { if (!savedBaseline) return false; @@ -269,7 +268,6 @@ export function ProjectPage() { async (layoutName: string) => { if (!token || !id) return; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>(`/api/projects/${id}/layouts`, { method: 'POST', @@ -287,13 +285,13 @@ export function ProjectPage() { }); } } catch (err) { - setError(err instanceof Error ? err.message : 'Create failed'); + showError(err instanceof Error ? err.message : 'Create failed'); throw err; } finally { setBusy(false); } }, - [token, id, project] + [token, id, project, showError] ); const deleteLayout = useCallback( @@ -307,7 +305,6 @@ export function ProjectPage() { return; } setBusy(true); - setError(null); try { await apiJson(`/api/projects/${id}/layouts/${layoutId}`, { method: 'DELETE', token }); const nextList = layoutsFull.filter((l) => l.id !== layoutId); @@ -349,12 +346,12 @@ export function ProjectPage() { } } } catch (err) { - setError(err instanceof Error ? err.message : 'Delete failed'); + showError(err instanceof Error ? err.message : 'Delete failed'); } finally { setBusy(false); } }, - [token, id, layoutsFull, activeLayoutId, project] + [token, id, layoutsFull, activeLayoutId, project, showError] ); useEffect(() => { @@ -410,7 +407,6 @@ export function ProjectPage() { const name = window.prompt('New layout name', suggested); if (!name?.trim()) return; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>(`/api/projects/${id}/layouts`, { method: 'POST', @@ -434,11 +430,11 @@ export function ProjectPage() { }); } } catch (err) { - setError(err instanceof Error ? err.message : 'Save as failed'); + showError(err instanceof Error ? err.message : 'Save as failed'); } finally { setBusy(false); } - }, [token, id, layoutName, editorState, project]); + }, [token, id, layoutName, editorState, project, showError]); const exitLayoutEditor = useCallback(() => { if (layoutIsDirty) { @@ -461,7 +457,6 @@ export function ProjectPage() { e.preventDefault(); if (!token || !id || !csvFile) return; setBusy(true); - setError(null); try { const text = await csvFile.text(); const parsed = parseCsvText(text); @@ -486,7 +481,7 @@ export function ProjectPage() { } setCsvFile(null); } catch (err) { - setError(err instanceof Error ? err.message : 'Import failed'); + showError(err instanceof Error ? err.message : 'Import failed'); } finally { setBusy(false); } @@ -496,7 +491,6 @@ export function ProjectPage() { e.preventDefault(); if (!token || !id || !file) return; setBusy(true); - setError(null); try { const fd = new FormData(); fd.append('file', file); @@ -514,7 +508,7 @@ export function ProjectPage() { setArtKey(''); await loadPipeline(); } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed'); + showError(err instanceof Error ? err.message : 'Upload failed'); } finally { setBusy(false); } @@ -523,7 +517,6 @@ export function ProjectPage() { async function saveCsvLink() { if (!token || !id) return; setBusy(true); - setError(null); try { const trimmed = csvUrlDraft.trim(); const { csvSourceUrl } = await apiJson<{ csvSourceUrl: string | null }>( @@ -537,7 +530,7 @@ export function ProjectPage() { if (project) setProject({ ...project, csvSourceUrl }); setCsvUrlDraft(csvSourceUrl ?? ''); } catch (err) { - setError(err instanceof Error ? err.message : 'Save link failed'); + showError(err instanceof Error ? err.message : 'Save link failed'); } finally { setBusy(false); } @@ -546,7 +539,6 @@ export function ProjectPage() { async function refreshCsvFromUrl() { if (!token || !id) return; setBusy(true); - setError(null); try { const res = await apiJson<{ csvData: CsvData; csvSourceUrl: string }>( `/api/projects/${id}/csv/refresh`, @@ -567,7 +559,7 @@ export function ProjectPage() { } setCsvUrlDraft(res.csvSourceUrl); } catch (err) { - setError(err instanceof Error ? err.message : 'Refresh failed'); + showError(err instanceof Error ? err.message : 'Refresh failed'); } finally { setBusy(false); } @@ -577,7 +569,6 @@ export function ProjectPage() { if (!token || !id) return; setExportPdfLoading(true); setExportPdfStatus(null); - setError(null); try { await apiJson<{ queued: boolean; projectId: string; timestamp: string }>( `/api/projects/${id}/export-pdf`, @@ -602,11 +593,11 @@ export function ProjectPage() { }, 8000); window.setTimeout(() => setExportPdfStatus(null), 12_000); } catch (err) { - setError(err instanceof Error ? err.message : 'Export failed'); + showError(err instanceof Error ? err.message : 'Export failed'); } finally { setExportPdfLoading(false); } - }, [token, id, loadPipeline, exportPdfDpi]); + }, [token, id, loadPipeline, exportPdfDpi, showError]); useEffect(() => { return () => { @@ -685,8 +676,6 @@ export function ProjectPage() {
    - {error &&

    {error}

    } - {tab === 'cards' && (
    msg && showError(msg)} onOpenLayoutInEditor={openLayoutInEditor} />
    @@ -712,7 +701,7 @@ export function ProjectPage() { lastUpdated: l.lastUpdated, }))} busy={busy} - onError={setError} + onError={(msg) => msg && showError(msg)} onOpenLayout={openLayoutInEditor} onCreateLayout={createLayoutFromList} onDeleteLayout={deleteLayout} From 7df47c62f710d9f9dbcc6654fa4dae14ad53c5e6 Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Thu, 9 Apr 2026 20:32:41 -0400 Subject: [PATCH 5/7] Stop cards re-render --- frontend/src/components/CardFace.tsx | 41 +++++++++++---- frontend/src/components/CardGroupsPanel.tsx | 58 +++++++++++---------- frontend/src/pages/ProjectPage.tsx | 1 - 3 files changed, 62 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/CardFace.tsx b/frontend/src/components/CardFace.tsx index 974821f..2195d3d 100644 --- a/frontend/src/components/CardFace.tsx +++ b/frontend/src/components/CardFace.tsx @@ -1,4 +1,4 @@ -import type { Ref } from 'react'; +import { memo, type Ref } from 'react'; import type { Layer as KonvaLayer } from 'konva/lib/Layer'; import { Group as KonvaGroup, Image as KonvaImage, Layer, Rect, Stage, Text } from 'react-konva'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; @@ -95,20 +95,32 @@ function CardNode({ return ; } -export function CardFace({ - state, - row, - assetUrls, - pixelWidth, - layerRef, -}: { +function rowDataEqual(a: Record, b: Record): boolean { + if (a === b) return true; + const ak = Object.keys(a); + if (ak.length !== Object.keys(b).length) return false; + for (const k of ak) { + if (a[k] !== b[k]) return false; + } + return true; +} + +type CardFaceProps = { state: LayoutStateV2; row: Record; assetUrls: Record; pixelWidth: number; /** For headless export: observe draw completion */ layerRef?: Ref; -}) { +}; + +function CardFaceInner({ + state, + row, + assetUrls, + pixelWidth, + layerRef, +}: CardFaceProps) { const scale = pixelWidth / state.width; const pixelHeight = state.height * scale; const bg = state.background ?? '#1e1e24'; @@ -126,3 +138,14 @@ export function CardFace({ ); } + +export const CardFace = memo(CardFaceInner, (prev, next) => { + return ( + prev.pixelWidth === next.pixelWidth && + prev.state === next.state && + prev.layerRef === next.layerRef && + prev.assetUrls === next.assetUrls && + rowDataEqual(prev.row, next.row) + ); +}); +CardFace.displayName = 'CardFace'; diff --git a/frontend/src/components/CardGroupsPanel.tsx b/frontend/src/components/CardGroupsPanel.tsx index 764675b..083456a 100644 --- a/frontend/src/components/CardGroupsPanel.tsx +++ b/frontend/src/components/CardGroupsPanel.tsx @@ -89,8 +89,8 @@ export function CardGroupsPanel(props: { layoutsFull: LayoutFull[]; assetUrls: Record; projectCsvSourceUrl: string | null; + /** Project-wide busy (e.g. layout save); card-group mutations use internal state so previews don’t thrash. */ busy: boolean; - onBusy: (b: boolean) => void; onError: (msg: string | null) => void; onOpenLayoutInEditor: (layoutId: string) => void; }) { @@ -101,12 +101,14 @@ export function CardGroupsPanel(props: { assetUrls, projectCsvSourceUrl, busy, - onBusy, onError, onOpenLayoutInEditor, } = props; const [groups, setGroups] = useState([]); + /** Mutations inside this panel only — avoids lifting `setBusy` to ProjectPage and re-rendering the whole page. */ + const [panelBusy, setPanelBusy] = useState(false); + const opsBusy = busy || panelBusy; const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [editingTitleId, setEditingTitleId] = useState(null); @@ -147,7 +149,7 @@ export function CardGroupsPanel(props: { const createGroup = useCallback(async () => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -159,14 +161,14 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Failed to create group'); } finally { - onBusy(false); + setPanelBusy(false); } - }, [token, projectId, onBusy, onError]); + }, [token, projectId, onError]); const updateGroup = useCallback( async (groupId: string, body: Record) => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -177,17 +179,17 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Update failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); const deleteGroup = useCallback( async (groupId: string) => { if (!token) return; if (!window.confirm('Delete this card group?')) return; - onBusy(true); + setPanelBusy(true); onError(null); try { await apiJson(`/api/projects/${projectId}/card-groups/${groupId}`, { @@ -199,16 +201,16 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Delete failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError, dataSourceMenuGroupId] + [token, projectId, onError, dataSourceMenuGroupId] ); const duplicateGroup = useCallback( async (groupId: string) => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -219,16 +221,16 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Duplicate failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); const refreshGroupCsv = useCallback( async (groupId: string, url: string | null) => { if (!token || !url?.trim()) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -239,10 +241,10 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Refresh failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); const saveDataSourceUrl = useCallback( @@ -296,7 +298,7 @@ export function CardGroupsPanel(props: {
    -
    + {info && (

    {info}