From 2ec6d3428a81d48c4d505580a6b5588f99906dd9 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:16:00 +0200 Subject: [PATCH 1/3] chore: add a formatter, matching the style this repo already writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit singleQuote=true was chosen by counting this repo's own imports, not by fleet decree. The fleet is genuinely split and the two repos that already had a .prettierrc disagreed with each other, so there was no standard to restore. Quote style does not cross repo boundaries; having a gate does. Markdown is ignored for now — prettier rewraps prose, which would bury the real diff. Co-Authored-By: Claude Opus 5 --- .prettierignore | 21 +++++++++++++++++++++ .prettierrc | 9 +++++++++ package-lock.json | 24 +++++++++++++++++++++++- package.json | 9 +++++++-- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1ed5ea5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..616247a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/package-lock.json b/package-lock.json index 30104a7..30da1d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,29 @@ "packages": { "": { "name": "printcraft", - "version": "0.0.0" + "version": "0.0.0", + "devDependencies": { + "prettier": "3.9.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } } } } diff --git a/package.json b/package.json index deea7c1..d892682 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,13 @@ "lint": "npm --prefix app run lint", "typecheck": "npm --prefix app run typecheck", "test": "npm --prefix app run test", - "verify": "npm --prefix app run verify", + "verify": "npm run format:check && npm --prefix app run verify", "build": "npm --prefix app run build", - "dev": "npm --prefix app run dev" + "dev": "npm --prefix app run dev", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "prettier": "3.9.6" } } From d666eedc19e5bbd1ce6b7436e95719e21f826163 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:16:54 +0200 Subject: [PATCH 2/3] style: format with prettier (85 files) Mechanical. No behaviour change. This SHA is listed in .git-blame-ignore-revs so `git blame` skips it. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 4 +- app/eslint.config.mjs | 14 +- app/next.config.ts | 10 +- app/postcss.config.mjs | 2 +- app/scripts/seed-roli-project.ts | 174 +++++------ app/src/app/api/compositions/route.ts | 46 +-- app/src/app/api/figures/[id]/route.ts | 46 +-- app/src/app/api/figures/route.ts | 46 +-- app/src/app/api/projects/[id]/route.ts | 53 ++-- app/src/app/api/projects/route.ts | 31 +- app/src/app/api/surfaces/route.ts | 48 +-- app/src/app/globals.css | 101 +++++-- app/src/app/layout.tsx | 63 ++-- app/src/app/login/page.tsx | 68 +++-- app/src/app/opengraph-image.tsx | 101 ++++--- app/src/app/page.tsx | 105 +++++-- app/src/app/project/[id]/compose/page.tsx | 55 ++-- app/src/app/project/[id]/export/page.tsx | 81 ++++-- app/src/app/project/[id]/figures/page.tsx | 34 ++- app/src/app/project/[id]/layout.tsx | 16 +- app/src/app/project/[id]/page.tsx | 12 +- app/src/app/project/[id]/style/page.tsx | 38 +-- app/src/app/project/[id]/surface/page.tsx | 231 +++++++++------ app/src/app/projects/new/page.tsx | 73 +++-- app/src/app/projects/page.tsx | 36 ++- app/src/app/register/page.tsx | 68 +++-- app/src/components/compose/CanvasToolbar.tsx | 197 ++++++++----- .../components/compose/CompositionCanvas.tsx | 212 +++++++------- app/src/components/compose/FigureLayer.tsx | 148 +++++----- app/src/components/figures/FigureCard.tsx | 81 +++--- app/src/components/figures/FigureUploader.tsx | 71 +++-- app/src/components/layout/AppShell.tsx | 100 ++++--- app/src/components/layout/ProjectStepNav.tsx | 58 ++-- app/src/components/projects/ProjectCard.tsx | 29 +- app/src/components/providers/AuthProvider.tsx | 54 ++-- .../components/providers/QueryProvider.tsx | 35 ++- app/src/components/styles/StyleGallery.tsx | 32 +- app/src/components/ui/ScrollReveal.tsx | 55 ++-- app/src/components/ui/badge.tsx | 47 ++- app/src/components/ui/button.tsx | 50 ++-- app/src/components/ui/card.tsx | 69 ++--- app/src/components/ui/dialog.tsx | 94 +++--- app/src/components/ui/dropdown-menu.tsx | 118 ++++---- app/src/components/ui/input.tsx | 16 +- app/src/components/ui/label.tsx | 16 +- app/src/components/ui/select.tsx | 89 +++--- app/src/components/ui/separator.tsx | 20 +- app/src/components/ui/skeleton.tsx | 10 +- app/src/components/ui/tabs.tsx | 53 ++-- app/src/components/ui/textarea.tsx | 14 +- app/src/components/ui/tooltip.tsx | 40 +-- app/src/hooks/useComposition.ts | 26 +- app/src/hooks/useFigures.ts | 33 +-- app/src/hooks/useProject.ts | 20 +- app/src/hooks/useProjects.ts | 23 +- app/src/hooks/useStyles.ts | 18 +- app/src/hooks/useSupabaseUpload.ts | 42 +-- app/src/hooks/useSurface.ts | 26 +- app/src/lib/api/ownership.test.ts | 123 ++++---- app/src/lib/api/ownership.ts | 18 +- app/src/lib/api/wired.test.ts | 42 +-- app/src/lib/config/project-steps.ts | 6 +- app/src/lib/config/surface-presets.ts | 22 +- app/src/lib/constants.ts | 2 +- app/src/lib/db/storage-policies.test.ts | 70 ++--- app/src/lib/domain/export.test.ts | 232 ++++++++------- app/src/lib/domain/export.ts | 114 ++++---- app/src/lib/domain/project-progress.test.ts | 62 ++-- app/src/lib/domain/project-progress.ts | 34 +-- app/src/lib/domain/surface.test.ts | 274 ++++++++++-------- app/src/lib/domain/surface.ts | 121 ++++---- app/src/lib/export/stage-export.ts | 76 +++-- app/src/lib/fetchJson.ts | 8 +- app/src/lib/schemas/validation.ts | 38 +-- app/src/lib/supabase/admin.ts | 8 +- app/src/lib/supabase/api-client.ts | 14 +- app/src/lib/supabase/client.ts | 6 +- app/src/lib/supabase/server.ts | 16 +- app/src/lib/supabase/storage.ts | 20 +- app/src/lib/utils.ts | 6 +- app/src/middleware.ts | 28 +- app/src/types/database.ts | 166 +++++------ app/vitest.config.ts | 6 +- projects/duschwand-roli/project.yaml | 2 +- templates/project/project.yaml | 14 +- 85 files changed, 2639 insertions(+), 2341 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a646416..554fa37 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,7 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 5 @@ -20,7 +20,7 @@ updates: update-types: [minor, patch] - package-ecosystem: github-actions - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 3 diff --git a/app/eslint.config.mjs b/app/eslint.config.mjs index 05e726d..626ca82 100644 --- a/app/eslint.config.mjs +++ b/app/eslint.config.mjs @@ -1,6 +1,6 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; const eslintConfig = defineConfig([ ...nextVitals, @@ -8,10 +8,10 @@ const eslintConfig = defineConfig([ // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ]), ]); diff --git a/app/next.config.ts b/app/next.config.ts index 89d65c4..d699d47 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -1,14 +1,14 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - output: "standalone", + output: 'standalone', images: { remotePatterns: [ { // Self-hosted Supabase storage (Hetzner migration 2026-06) - protocol: "https", - hostname: "supabase.orangecat.ch", - pathname: "/storage/v1/object/**", + protocol: 'https', + hostname: 'supabase.orangecat.ch', + pathname: '/storage/v1/object/**', }, ], }, diff --git a/app/postcss.config.mjs b/app/postcss.config.mjs index 61e3684..297374d 100644 --- a/app/postcss.config.mjs +++ b/app/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/app/scripts/seed-roli-project.ts b/app/scripts/seed-roli-project.ts index 1d25711..cfa8576 100644 --- a/app/scripts/seed-roli-project.ts +++ b/app/scripts/seed-roli-project.ts @@ -3,96 +3,103 @@ * Run with: npx tsx scripts/seed-roli-project.ts */ -import { createClient } from '@supabase/supabase-js' -import { readFileSync } from 'fs' -import { basename } from 'path' +import { createClient } from '@supabase/supabase-js'; +import { readFileSync } from 'fs'; +import { basename } from 'path'; -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://supabase.orangecat.ch' -const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY! +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://supabase.orangecat.ch'; +const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!; if (!SERVICE_ROLE_KEY) { - console.error('Set SUPABASE_SERVICE_ROLE_KEY env var') - process.exit(1) + console.error('Set SUPABASE_SERVICE_ROLE_KEY env var'); + process.exit(1); } -const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY) -const BUCKET = 'project-files' -const USER_ID = 'fee8f90d-30b0-4b38-9495-c65acd17eef6' +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY); +const BUCKET = 'project-files'; +const USER_ID = 'fee8f90d-30b0-4b38-9495-c65acd17eef6'; interface FigureDef { - label: string - originalPath: string - styledPath?: string - zDepth: number + label: string; + originalPath: string; + styledPath?: string; + zDepth: number; } const FIGURES: FigureDef[] = [ { label: 'Roli + Freundin (B-AP 670)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.33.267.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.33.267.jpeg', styledPath: '/home/g/Dokumente/Duschwand/edited/bild1-green-bap670.png', zDepth: 5, }, { label: 'Gela + Marco (Weiss, Streifen)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.39.5122.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.39.5122.jpeg', styledPath: '/home/g/Dokumente/Duschwand/edited/bild2-white-stripy.png', zDepth: 3, }, { label: 'Roma + Andrea (Teal K-D)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.33.267.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2026-01-03 at 11.33.267.jpeg', styledPath: '/home/g/Dokumente/Duschwand/edited/bild3-teal-kd-black.png', zDepth: 2, }, { label: 'Andreas + Freundin (AB-N 274, Titanic)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-21 at 12.31.53.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-21 at 12.31.53.jpeg', styledPath: '/home/g/Dokumente/Duschwand/edited/bild4-blue-abn274.png', zDepth: 4, }, { label: 'Marco (Foilboard + Hund)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-31 at 10.44.302.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-31 at 10.44.302.jpeg', zDepth: 6, }, { label: 'Teus (Blue 66568 JETRANGER)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-31 at 10.14.146.jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-31 at 10.14.146.jpeg', zDepth: 1, }, { label: 'Alberto (Weiss, VW Amphibie)', - originalPath: '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-10 at 09.21.21 (11).jpeg', + originalPath: + '/home/g/Dokumente/Duschwand/real-photos/WhatsApp Image 2025-12-10 at 09.21.21 (11).jpeg', zDepth: 0, }, -] +]; async function uploadFile(localPath: string, storagePath: string): Promise { - const file = readFileSync(localPath) - const ext = localPath.split('.').pop()! - const contentType = ext === 'png' ? 'image/png' : 'image/jpeg' + const file = readFileSync(localPath); + const ext = localPath.split('.').pop()!; + const contentType = ext === 'png' ? 'image/png' : 'image/jpeg'; const { data, error } = await supabase.storage .from(BUCKET) - .upload(storagePath, file, { contentType, upsert: true }) + .upload(storagePath, file, { contentType, upsert: true }); - if (error) throw new Error(`Upload failed for ${storagePath}: ${error.message}`) - console.log(` ✓ Uploaded: ${storagePath}`) - return data.path + if (error) throw new Error(`Upload failed for ${storagePath}: ${error.message}`); + console.log(` ✓ Uploaded: ${storagePath}`); + return data.path; } async function main() { - console.log('Creating Roli Duschwand project...\n') + console.log('Creating Roli Duschwand project...\n'); // 1. Get the retro-travel style const { data: style } = await supabase .from('styles') .select('id') .eq('slug', 'retro-travel') - .single() + .single(); - if (!style) throw new Error('Retro travel style not found') + if (!style) throw new Error('Retro travel style not found'); // 2. Create project const { data: project, error: projErr } = await supabase @@ -102,77 +109,74 @@ async function main() { name: 'Duschwand Roli', description: 'Amphicar poster for shower wall on River Queen houseboat', style_id: style.id, - scene_description: 'Lake Garda at golden hour — warm sunset sky, green cypress hills, Italian villages with terracotta roofs, mountains in the distance, calm water with golden reflections.', + scene_description: + 'Lake Garda at golden hour — warm sunset sky, green cypress hills, Italian villages with terracotta roofs, mountains in the distance, calm water with golden reflections.', status: 'composing', }) .select() - .single() + .single(); - if (projErr) throw new Error(`Project creation failed: ${projErr.message}`) - console.log(`✓ Project created: ${project.id}\n`) + if (projErr) throw new Error(`Project creation failed: ${projErr.message}`); + console.log(`✓ Project created: ${project.id}\n`); // 3. Create surface (Roli's exact Duschwand dimensions) - const { error: surfErr } = await supabase - .from('surfaces') - .insert({ - project_id: project.id, - type: 'glass', - panels: [ - { width_cm: 77.5, height_cm: 190 }, - { width_cm: 119.5, height_cm: 190 }, - ], - seam_positions: [{ x_cm: 77.5 }], - dead_zones: [ - { x_cm: 18.75, y_cm: 0, width_cm: 40, height_cm: 45, reason: 'Dusch Armatur' }, - ], - dpi_target: 200, - bleed_mm: 3, - }) - - if (surfErr) throw new Error(`Surface creation failed: ${surfErr.message}`) - console.log('✓ Surface created (77.5 + 119.5 cm, with Armatur dead zone)\n') + const { error: surfErr } = await supabase.from('surfaces').insert({ + project_id: project.id, + type: 'glass', + panels: [ + { width_cm: 77.5, height_cm: 190 }, + { width_cm: 119.5, height_cm: 190 }, + ], + seam_positions: [{ x_cm: 77.5 }], + dead_zones: [{ x_cm: 18.75, y_cm: 0, width_cm: 40, height_cm: 45, reason: 'Dusch Armatur' }], + dpi_target: 200, + bleed_mm: 3, + }); + + if (surfErr) throw new Error(`Surface creation failed: ${surfErr.message}`); + console.log('✓ Surface created (77.5 + 119.5 cm, with Armatur dead zone)\n'); // 4. Upload images and create figures for (const fig of FIGURES) { - console.log(`Processing: ${fig.label}`) + console.log(`Processing: ${fig.label}`); // Upload original - const origFilename = `original-${basename(fig.originalPath).replace(/\s+/g, '_')}` - const origStoragePath = `${USER_ID}/${project.id}/originals/${origFilename}` - const origPath = await uploadFile(fig.originalPath, origStoragePath) + const origFilename = `original-${basename(fig.originalPath).replace(/\s+/g, '_')}`; + const origStoragePath = `${USER_ID}/${project.id}/originals/${origFilename}`; + const origPath = await uploadFile(fig.originalPath, origStoragePath); // Upload styled (if exists) - let styledPath: string | null = null + let styledPath: string | null = null; if (fig.styledPath) { - const styledFilename = basename(fig.styledPath) - const styledStoragePath = `${USER_ID}/${project.id}/styled/${styledFilename}` - styledPath = await uploadFile(fig.styledPath, styledStoragePath) + const styledFilename = basename(fig.styledPath); + const styledStoragePath = `${USER_ID}/${project.id}/styled/${styledFilename}`; + styledPath = await uploadFile(fig.styledPath, styledStoragePath); } // Create figure record - const { error: figErr } = await supabase - .from('figures') - .insert({ - project_id: project.id, - label: fig.label, - original_photo_url: origPath, - styled_url: styledPath, - status: styledPath ? 'styled' : 'uploaded', - z_depth: fig.zDepth, - position_x: 0.5, - position_y: 0.5, - scale: 1.0, - }) - - if (figErr) throw new Error(`Figure creation failed: ${figErr.message}`) - console.log(` ✓ Figure created: ${fig.label} ${styledPath ? '(with styled)' : '(original only)'}\n`) + const { error: figErr } = await supabase.from('figures').insert({ + project_id: project.id, + label: fig.label, + original_photo_url: origPath, + styled_url: styledPath, + status: styledPath ? 'styled' : 'uploaded', + z_depth: fig.zDepth, + position_x: 0.5, + position_y: 0.5, + scale: 1.0, + }); + + if (figErr) throw new Error(`Figure creation failed: ${figErr.message}`); + console.log( + ` ✓ Figure created: ${fig.label} ${styledPath ? '(with styled)' : '(original only)'}\n`, + ); } - console.log('=== DONE ===') - console.log(`Project URL: https://printcraft.orangecat.ch/project/${project.id}/figures`) + console.log('=== DONE ==='); + console.log(`Project URL: https://printcraft.orangecat.ch/project/${project.id}/figures`); } -main().catch(err => { - console.error('FAILED:', err.message) - process.exit(1) -}) +main().catch((err) => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/app/src/app/api/compositions/route.ts b/app/src/app/api/compositions/route.ts index d1c982b..bc4a253 100644 --- a/app/src/app/api/compositions/route.ts +++ b/app/src/app/api/compositions/route.ts @@ -1,15 +1,16 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { upsertCompositionSchema } from '@/lib/schemas/validation' -import { ownsProject } from '@/lib/api/ownership' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { upsertCompositionSchema } from '@/lib/schemas/validation'; +import { ownsProject } from '@/lib/api/ownership'; export async function GET(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const projectId = request.nextUrl.searchParams.get('project_id') - if (!projectId) return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }) + const projectId = request.nextUrl.searchParams.get('project_id'); + if (!projectId) + return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }); if (!(await ownsProject(supabase, projectId, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } const { data, error } = await supabase @@ -18,23 +19,26 @@ export async function GET(request: NextRequest) { .eq('project_id', projectId) .order('version', { ascending: false }) .limit(1) - .single() + .single(); - if (error) return NextResponse.json({ success: false, data: null }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, data: null }); + return NextResponse.json({ success: true, data }); } export async function POST(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = upsertCompositionSchema.safeParse(body) + const body = await request.json(); + const parsed = upsertCompositionSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } if (!(await ownsProject(supabase, parsed.data.project_id, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } const { data: existing } = await supabase @@ -43,16 +47,16 @@ export async function POST(request: NextRequest) { .eq('project_id', parsed.data.project_id) .order('version', { ascending: false }) .limit(1) - .single() + .single(); - const nextVersion = (existing?.version ?? 0) + 1 + const nextVersion = (existing?.version ?? 0) + 1; const { data, error } = await supabase .from('compositions') .insert({ ...parsed.data, version: nextVersion }) .select() - .single() + .single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }, { status: 201 }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }, { status: 201 }); } diff --git a/app/src/app/api/figures/[id]/route.ts b/app/src/app/api/figures/[id]/route.ts index 318c366..9b48e23 100644 --- a/app/src/app/api/figures/[id]/route.ts +++ b/app/src/app/api/figures/[id]/route.ts @@ -1,21 +1,24 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { updateFigureSchema } from '@/lib/schemas/validation' -import { ownsFigure } from '@/lib/api/ownership' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { updateFigureSchema } from '@/lib/schemas/validation'; +import { ownsFigure } from '@/lib/api/ownership'; -type RouteContext = { params: Promise<{ id: string }> } +type RouteContext = { params: Promise<{ id: string }> }; export async function PATCH(request: NextRequest, context: RouteContext) { - const { id } = await context.params - const { supabase, userId } = await getApiClient() + const { id } = await context.params; + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = updateFigureSchema.safeParse(body) + const body = await request.json(); + const parsed = updateFigureSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } if (!(await ownsFigure(supabase, id, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } const { data, error } = await supabase @@ -23,25 +26,22 @@ export async function PATCH(request: NextRequest, context: RouteContext) { .update(parsed.data) .eq('id', id) .select() - .single() + .single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }); } export async function DELETE(_request: NextRequest, context: RouteContext) { - const { id } = await context.params - const { supabase, userId } = await getApiClient() + const { id } = await context.params; + const { supabase, userId } = await getApiClient(); if (!(await ownsFigure(supabase, id, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } - const { error } = await supabase - .from('figures') - .delete() - .eq('id', id) + const { error } = await supabase.from('figures').delete().eq('id', id); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); } diff --git a/app/src/app/api/figures/route.ts b/app/src/app/api/figures/route.ts index 3aef734..7678c8e 100644 --- a/app/src/app/api/figures/route.ts +++ b/app/src/app/api/figures/route.ts @@ -1,45 +1,45 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { createFigureSchema } from '@/lib/schemas/validation' -import { ownsProject } from '@/lib/api/ownership' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { createFigureSchema } from '@/lib/schemas/validation'; +import { ownsProject } from '@/lib/api/ownership'; export async function GET(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const projectId = request.nextUrl.searchParams.get('project_id') - if (!projectId) return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }) + const projectId = request.nextUrl.searchParams.get('project_id'); + if (!projectId) + return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }); if (!(await ownsProject(supabase, projectId, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } const { data, error } = await supabase .from('figures') .select('*') .eq('project_id', projectId) - .order('z_depth', { ascending: true }) + .order('z_depth', { ascending: true }); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }); } export async function POST(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = createFigureSchema.safeParse(body) + const body = await request.json(); + const parsed = createFigureSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } if (!(await ownsProject(supabase, parsed.data.project_id, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } - const { data, error } = await supabase - .from('figures') - .insert(parsed.data) - .select() - .single() + const { data, error } = await supabase.from('figures').insert(parsed.data).select().single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }, { status: 201 }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }, { status: 201 }); } diff --git a/app/src/app/api/projects/[id]/route.ts b/app/src/app/api/projects/[id]/route.ts index c021e88..0f2b18e 100644 --- a/app/src/app/api/projects/[id]/route.ts +++ b/app/src/app/api/projects/[id]/route.ts @@ -1,12 +1,12 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { updateProjectSchema } from '@/lib/schemas/validation' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { updateProjectSchema } from '@/lib/schemas/validation'; -type RouteContext = { params: Promise<{ id: string }> } +type RouteContext = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, context: RouteContext) { - const { id } = await context.params - const { supabase, userId } = await getApiClient() + const { id } = await context.params; + const { supabase, userId } = await getApiClient(); // Scoped to the caller, not just the id they sent — this select pulls every // figure in the project, which is photographs of real people. @@ -15,20 +15,23 @@ export async function GET(_request: NextRequest, context: RouteContext) { .select('*, style:styles(*), figures(*)') .eq('id', id) .eq('user_id', userId) - .single() + .single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 404 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 404 }); + return NextResponse.json({ success: true, data }); } export async function PATCH(request: NextRequest, context: RouteContext) { - const { id } = await context.params - const { supabase, userId } = await getApiClient() + const { id } = await context.params; + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = updateProjectSchema.safeParse(body) + const body = await request.json(); + const parsed = updateProjectSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } const { data, error } = await supabase @@ -37,23 +40,19 @@ export async function PATCH(request: NextRequest, context: RouteContext) { .eq('id', id) .eq('user_id', userId) .select() - .maybeSingle() + .maybeSingle(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + if (!data) return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); + return NextResponse.json({ success: true, data }); } export async function DELETE(_request: NextRequest, context: RouteContext) { - const { id } = await context.params - const { supabase, userId } = await getApiClient() + const { id } = await context.params; + const { supabase, userId } = await getApiClient(); - const { error } = await supabase - .from('projects') - .delete() - .eq('id', id) - .eq('user_id', userId) + const { error } = await supabase.from('projects').delete().eq('id', id).eq('user_id', userId); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); } diff --git a/app/src/app/api/projects/route.ts b/app/src/app/api/projects/route.ts index 95db0c0..f35c61f 100644 --- a/app/src/app/api/projects/route.ts +++ b/app/src/app/api/projects/route.ts @@ -1,9 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { createProjectSchema } from '@/lib/schemas/validation' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { createProjectSchema } from '@/lib/schemas/validation'; export async function GET() { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); const { data, error } = await supabase .from('projects') @@ -11,27 +11,30 @@ export async function GET() { // from these rows rather than from a status column nothing ever wrote. .select('*, style:styles(*), figures(id), surfaces(id), compositions(id)') .eq('user_id', userId) - .order('updated_at', { ascending: false }) + .order('updated_at', { ascending: false }); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }); } export async function POST(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = createProjectSchema.safeParse(body) + const body = await request.json(); + const parsed = createProjectSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } const { data, error } = await supabase .from('projects') .insert({ ...parsed.data, user_id: userId }) .select() - .single() + .single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }, { status: 201 }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }, { status: 201 }); } diff --git a/app/src/app/api/surfaces/route.ts b/app/src/app/api/surfaces/route.ts index 3a7bc09..b586346 100644 --- a/app/src/app/api/surfaces/route.ts +++ b/app/src/app/api/surfaces/route.ts @@ -1,49 +1,49 @@ -import { NextResponse, type NextRequest } from 'next/server' -import { getApiClient } from '@/lib/supabase/api-client' -import { upsertSurfaceSchema } from '@/lib/schemas/validation' -import { ownsProject } from '@/lib/api/ownership' +import { NextResponse, type NextRequest } from 'next/server'; +import { getApiClient } from '@/lib/supabase/api-client'; +import { upsertSurfaceSchema } from '@/lib/schemas/validation'; +import { ownsProject } from '@/lib/api/ownership'; export async function GET(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const projectId = request.nextUrl.searchParams.get('project_id') - if (!projectId) return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }) + const projectId = request.nextUrl.searchParams.get('project_id'); + if (!projectId) + return NextResponse.json({ success: false, error: 'project_id required' }, { status: 400 }); if (!(await ownsProject(supabase, projectId, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } const { data, error } = await supabase .from('surfaces') .select('*') .eq('project_id', projectId) - .single() + .single(); - if (error) return NextResponse.json({ success: false, data: null }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, data: null }); + return NextResponse.json({ success: true, data }); } export async function POST(request: NextRequest) { - const { supabase, userId } = await getApiClient() + const { supabase, userId } = await getApiClient(); - const body = await request.json() - const parsed = upsertSurfaceSchema.safeParse(body) + const body = await request.json(); + const parsed = upsertSurfaceSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ success: false, error: 'Invalid data', details: parsed.error.flatten() }, { status: 400 }) + return NextResponse.json( + { success: false, error: 'Invalid data', details: parsed.error.flatten() }, + { status: 400 }, + ); } if (!(await ownsProject(supabase, parsed.data.project_id, userId))) { - return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); } // Upsert: delete existing surface for project, then insert - await supabase.from('surfaces').delete().eq('project_id', parsed.data.project_id) + await supabase.from('surfaces').delete().eq('project_id', parsed.data.project_id); - const { data, error } = await supabase - .from('surfaces') - .insert(parsed.data) - .select() - .single() + const { data, error } = await supabase.from('surfaces').insert(parsed.data).select().single(); - if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - return NextResponse.json({ success: true, data }) + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + return NextResponse.json({ success: true, data }); } diff --git a/app/src/app/globals.css b/app/src/app/globals.css index fbc9dda..936430e 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1,6 +1,6 @@ -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; @custom-variant dark (&:is(.dark *)); @@ -182,11 +182,7 @@ background-clip: text; } .text-gradient-subtle { - background: linear-gradient( - 135deg, - oklch(0.95 0 0) 0%, - oklch(0.7 0 0) 100% - ); + background: linear-gradient(135deg, oklch(0.95 0 0) 0%, oklch(0.7 0 0) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -216,7 +212,9 @@ transparent 60%, oklch(0.78 0.14 70 / 8%) ); - -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; @@ -243,7 +241,13 @@ /* Animated dashed border for upload zones */ @keyframes dash-march { - to { background-position: 100% 0, 0 100%, 0 0, 100% 100%; } + to { + background-position: + 100% 0, + 0 100%, + 0 0, + 100% 100%; + } } .upload-zone-active { background-image: @@ -251,8 +255,16 @@ linear-gradient(90deg, oklch(0.78 0.14 70 / 60%) 50%, transparent 50%), linear-gradient(0deg, oklch(0.78 0.14 70 / 60%) 50%, transparent 50%), linear-gradient(0deg, oklch(0.78 0.14 70 / 60%) 50%, transparent 50%); - background-size: 12px 1.5px, 12px 1.5px, 1.5px 12px, 1.5px 12px; - background-position: 0 0, 0 100%, 0 0, 100% 0; + background-size: + 12px 1.5px, + 12px 1.5px, + 1.5px 12px, + 1.5px 12px; + background-position: + 0 0, + 0 100%, + 0 0, + 100% 0; background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; animation: dash-march 1s linear infinite; } @@ -273,12 +285,12 @@ transition: all 0.2s; white-space: nowrap; } -.segmented-control button[data-active="true"] { +.segmented-control button[data-active='true'] { background: oklch(0.78 0.14 70); color: oklch(0.12 0 0); box-shadow: 0 1px 3px oklch(0 0 0 / 20%); } -.segmented-control button:not([data-active="true"]):hover { +.segmented-control button:not([data-active='true']):hover { color: oklch(0.8 0 0); } @@ -293,7 +305,13 @@ /* Section divider gradient */ .section-divider { height: 1px; - background: linear-gradient(90deg, transparent, oklch(1 0 0 / 8%) 30%, oklch(1 0 0 / 8%) 70%, transparent); + background: linear-gradient( + 90deg, + transparent, + oklch(1 0 0 / 8%) 30%, + oklch(1 0 0 / 8%) 70%, + transparent + ); } /* Stagger animation for grid items */ @@ -309,8 +327,12 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes slideInFromBottom { @@ -325,13 +347,22 @@ } @keyframes subtlePulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } } @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } } .animate-fade-in { @@ -365,7 +396,9 @@ /* Card hover lift */ .card-hover { cursor: pointer; - transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s ease; + transition: + transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.25s ease; } .card-hover:hover { transform: translateY(-2px); @@ -407,7 +440,9 @@ .reveal { opacity: 0; transform: translateY(20px); - transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); + transition: + opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); } .reveal.revealed { opacity: 1; @@ -437,7 +472,13 @@ /* Gradient section separator */ .section-gradient-sep { height: 80px; - background: linear-gradient(180deg, transparent 0%, oklch(1 0 0 / 3%) 40%, oklch(1 0 0 / 3%) 60%, transparent 100%); + background: linear-gradient( + 180deg, + transparent 0%, + oklch(1 0 0 / 3%) 40%, + oklch(1 0 0 / 3%) 60%, + transparent 100% + ); position: relative; } .section-gradient-sep::after { @@ -447,7 +488,13 @@ left: 10%; right: 10%; height: 1px; - background: linear-gradient(90deg, transparent, oklch(1 0 0 / 8%) 30%, oklch(1 0 0 / 8%) 70%, transparent); + background: linear-gradient( + 90deg, + transparent, + oklch(1 0 0 / 8%) 30%, + oklch(1 0 0 / 8%) 70%, + transparent + ); } /* Canvas grid background */ @@ -462,7 +509,9 @@ /* Focus glow for auth inputs */ .input-focus-glow:focus { border-color: oklch(0.78 0.14 70 / 40%) !important; - box-shadow: 0 0 0 3px oklch(0.78 0.14 70 / 10%), 0 0 20px oklch(0.78 0.14 70 / 5%); + box-shadow: + 0 0 0 3px oklch(0.78 0.14 70 / 10%), + 0 0 20px oklch(0.78 0.14 70 / 5%); } /* Hide scrollbar but keep scrolling */ diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index a73304a..bdcef92 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -1,49 +1,52 @@ -import type { Metadata } from "next" -import Script from "next/script" -import { Geist, Geist_Mono } from "next/font/google" -import { Toaster } from "sonner" -import { AuthProvider } from "@/components/providers/AuthProvider" -import { QueryProvider } from "@/components/providers/QueryProvider" -import "./globals.css" +import type { Metadata } from 'next'; +import Script from 'next/script'; +import { Geist, Geist_Mono } from 'next/font/google'; +import { Toaster } from 'sonner'; +import { AuthProvider } from '@/components/providers/AuthProvider'; +import { QueryProvider } from '@/components/providers/QueryProvider'; +import './globals.css'; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}) + variable: '--font-geist-sans', + subsets: ['latin'], +}); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}) + variable: '--font-geist-mono', + subsets: ['latin'], +}); export const metadata: Metadata = { - title: "PrintCraft — Scene Composer for Physical Art", - description: "Turn separate photos of real people into one unified artwork — printed on surfaces that matter.", + title: 'PrintCraft — Scene Composer for Physical Art', + description: + 'Turn separate photos of real people into one unified artwork — printed on surfaces that matter.', // Where the site ACTUALLY serves. printcraft.app does not serve this app, so // the generated og:image resolved to https://printcraft.app/opengraph-image // and 404'd — the preview was advertised and discarded by every scraper. - metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://printcraft.orangecat.ch"), + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://printcraft.orangecat.ch'), openGraph: { - title: "PrintCraft — Scene Composer for Physical Art", - description: "Turn separate photos of real people into one unified artwork — printed on surfaces that matter.", - images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "PrintCraft" }], - type: "website", + title: 'PrintCraft — Scene Composer for Physical Art', + description: + 'Turn separate photos of real people into one unified artwork — printed on surfaces that matter.', + images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'PrintCraft' }], + type: 'website', }, twitter: { - card: "summary_large_image", - title: "PrintCraft — Scene Composer for Physical Art", - description: "Turn separate photos of real people into one unified artwork — printed on surfaces that matter.", - images: ["/og-image.png"], + card: 'summary_large_image', + title: 'PrintCraft — Scene Composer for Physical Art', + description: + 'Turn separate photos of real people into one unified artwork — printed on surfaces that matter.', + images: ['/og-image.png'], }, other: { - "theme-color": "#1a1a1a", + 'theme-color': '#1a1a1a', }, -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( - - {children} - + {children} @@ -68,5 +69,5 @@ export default function RootLayout({ )} - ) + ); } diff --git a/app/src/app/login/page.tsx b/app/src/app/login/page.tsx index e5f28b6..2c2a90d 100644 --- a/app/src/app/login/page.tsx +++ b/app/src/app/login/page.tsx @@ -1,34 +1,34 @@ -'use client' +'use client'; -import { useState } from 'react' -import { useRouter } from 'next/navigation' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { createClient } from '@/lib/supabase/client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; export default function LoginPage() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - const router = useRouter() - const supabase = createClient() + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const supabase = createClient(); async function handleLogin(e: React.FormEvent) { - e.preventDefault() - setLoading(true) - setError('') + e.preventDefault(); + setLoading(true); + setError(''); - const { error } = await supabase.auth.signInWithPassword({ email, password }) + const { error } = await supabase.auth.signInWithPassword({ email, password }); if (error) { - setError(error.message) - setLoading(false) + setError(error.message); + setLoading(false); } else { - router.push('/projects') - router.refresh() + router.push('/projects'); + router.refresh(); } } @@ -48,33 +48,43 @@ export default function LoginPage() {
- + setEmail(e.target.value)} + onChange={(e) => setEmail(e.target.value)} className="h-12 text-base bg-transparent border-white/[0.08] input-focus-glow transition-all duration-200" placeholder="you@example.com" required />
- + setPassword(e.target.value)} + onChange={(e) => setPassword(e.target.value)} className="h-12 text-base bg-transparent border-white/[0.08] input-focus-glow transition-all duration-200" placeholder="Your password" required />
{error && ( -

{error}

+

+ {error} +

)} -
@@ -87,5 +97,5 @@ export default function LoginPage() {
- ) + ); } diff --git a/app/src/app/opengraph-image.tsx b/app/src/app/opengraph-image.tsx index 896bcf5..eae5fbb 100644 --- a/app/src/app/opengraph-image.tsx +++ b/app/src/app/opengraph-image.tsx @@ -1,69 +1,68 @@ -import { ImageResponse } from 'next/og' +import { ImageResponse } from 'next/og'; -export const runtime = 'edge' -export const alt = 'PrintCraft — Scene Composer for Physical Art' -export const size = { width: 1200, height: 630 } -export const contentType = 'image/png' +export const runtime = 'edge'; +export const alt = 'PrintCraft — Scene Composer for Physical Art'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; export default function OgImage() { return new ImageResponse( - ( +
-
- PrintCraft -
-
- Scene Composer for Physical Art -
-
- Turn separate photos of real people into one unified artwork — printed on surfaces that matter. -
+ PrintCraft +
+
+ Scene Composer for Physical Art +
+
+ Turn separate photos of real people into one unified artwork — printed on surfaces that + matter.
- ), - { ...size } - ) +
, + { ...size }, + ); } diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 2907699..f5a4cd9 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; -import Link from 'next/link' -import { useAuth } from '@/components/providers/AuthProvider' -import { AppShell } from '@/components/layout/AppShell' -import { ScrollReveal } from '@/components/ui/ScrollReveal' -import { Button } from '@/components/ui/button' +import Link from 'next/link'; +import { useAuth } from '@/components/providers/AuthProvider'; +import { AppShell } from '@/components/layout/AppShell'; +import { ScrollReveal } from '@/components/ui/ScrollReveal'; +import { Button } from '@/components/ui/button'; import { Users, Palette, @@ -16,25 +16,28 @@ import { Camera, Frame, Gem, -} from 'lucide-react' +} from 'lucide-react'; const FEATURES = [ { icon: Camera, title: 'Upload Real Photos', - description: 'Drop in photos of the people you love. Each one becomes a figure in your composition.', + description: + 'Drop in photos of the people you love. Each one becomes a figure in your composition.', }, { icon: Palette, title: 'Choose an Art Style', - description: 'Retro travel poster, oil portrait, watercolor, pop art — pick the emotional tone that fits.', + description: + 'Retro travel poster, oil portrait, watercolor, pop art — pick the emotional tone that fits.', }, { icon: Frame, title: 'Print on Any Surface', - description: 'Shower glass, canvas, metal, tile. Define your physical surface and we handle the rest.', + description: + 'Shower glass, canvas, metal, tile. Define your physical surface and we handle the rest.', }, -] +]; const STEPS = [ { number: '01', title: 'Upload', description: 'Add photos of each person', icon: Users }, @@ -42,28 +45,31 @@ const STEPS = [ { number: '03', title: 'Surface', description: 'Define the physical print', icon: Ruler }, { number: '04', title: 'Compose', description: 'Arrange the scene', icon: Layers }, { number: '05', title: 'Export', description: 'Download print-ready files', icon: Download }, -] +]; const SHOWCASES = [ { title: 'Memorial Portraits', - description: 'Bring together people who were never in the same place — grandparents with grandchildren, friends across decades.', + description: + 'Bring together people who were never in the same place — grandparents with grandchildren, friends across decades.', tone: 'Sorrow + Love + Longing', }, { title: 'Celebration Artwork', - description: 'A friend group scattered across continents, reunited in a single scene. The gathering that should have happened.', + description: + 'A friend group scattered across continents, reunited in a single scene. The gathering that should have happened.', tone: 'Joy + Nostalgia', }, { title: 'Passion Projects', - description: 'Enthusiast communities united in their element. Car clubs, musicians, athletes — together in art.', + description: + 'Enthusiast communities united in their element. Car clubs, musicians, athletes — together in art.', tone: 'Pride + Identity', }, -] +]; export default function Home() { - const { user } = useAuth() + const { user } = useAuth(); return ( @@ -85,19 +91,26 @@ export default function Home() {

- Turn separate photos of real people into one unified artwork — - printed on surfaces that matter. Shower glass, canvas, metal, tile. + Turn separate photos of real people into one unified artwork — printed on surfaces that + matter. Shower glass, canvas, metal, tile.

- - @@ -124,7 +137,11 @@ export default function Home() {
- + {FEATURES.map((feature) => (
{/* How it works — Step flow */} -
+

@@ -155,11 +175,17 @@ export default function Home() {

- + {STEPS.map((step, i) => (
- {step.number} + + {step.number} +
@@ -186,7 +212,11 @@ export default function Home() {
- + {SHOWCASES.map((item) => (
Scene composer for physical art

@@ -245,5 +290,5 @@ export default function Home() {

- ) + ); } diff --git a/app/src/app/project/[id]/compose/page.tsx b/app/src/app/project/[id]/compose/page.tsx index 153a51d..2dfe759 100644 --- a/app/src/app/project/[id]/compose/page.tsx +++ b/app/src/app/project/[id]/compose/page.tsx @@ -1,32 +1,35 @@ -'use client' +'use client'; -import { use } from 'react' -import Link from 'next/link' -import dynamic from 'next/dynamic' -import { useFigures } from '@/hooks/useFigures' -import { useSurface } from '@/hooks/useSurface' -import { Skeleton } from '@/components/ui/skeleton' -import { Button } from '@/components/ui/button' -import { Layers, AlertCircle, ArrowLeft } from 'lucide-react' +import { use } from 'react'; +import Link from 'next/link'; +import dynamic from 'next/dynamic'; +import { useFigures } from '@/hooks/useFigures'; +import { useSurface } from '@/hooks/useSurface'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Layers, AlertCircle, ArrowLeft } from 'lucide-react'; const CompositionCanvas = dynamic( - () => import('@/components/compose/CompositionCanvas').then(m => ({ default: m.CompositionCanvas })), - { ssr: false, loading: () => } -) + () => + import('@/components/compose/CompositionCanvas').then((m) => ({ + default: m.CompositionCanvas, + })), + { ssr: false, loading: () => }, +); export default function ComposePage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const { data: figures, isLoading: figuresLoading } = useFigures(id) - const { data: surface, isLoading: surfaceLoading } = useSurface(id) + const { id } = use(params); + const { data: figures, isLoading: figuresLoading } = useFigures(id); + const { data: surface, isLoading: surfaceLoading } = useSurface(id); - const isLoading = figuresLoading || surfaceLoading + const isLoading = figuresLoading || surfaceLoading; if (isLoading) { return (
- ) + ); } if (!surface) { @@ -45,10 +48,10 @@ export default function ComposePage({ params }: { params: Promise<{ id: string } - ) + ); } - const styledFigures = figures?.filter(f => f.styled_url || f.original_photo_url) ?? [] + const styledFigures = figures?.filter((f) => f.styled_url || f.original_photo_url) ?? []; if (styledFigures.length === 0) { return ( @@ -57,16 +60,14 @@ export default function ComposePage({ params }: { params: Promise<{ id: string }

No figures to compose

-

- Upload photos and their styled versions first. -

+

Upload photos and their styled versions first.

- ) + ); } return ( @@ -77,11 +78,7 @@ export default function ComposePage({ params }: { params: Promise<{ id: string } Drag figures into position. Red dashed lines show panel seams.

- + - ) + ); } diff --git a/app/src/app/project/[id]/export/page.tsx b/app/src/app/project/[id]/export/page.tsx index 0a40168..ac9f397 100644 --- a/app/src/app/project/[id]/export/page.tsx +++ b/app/src/app/project/[id]/export/page.tsx @@ -1,19 +1,19 @@ -'use client' +'use client'; -import { use } from 'react' -import { useSurface } from '@/hooks/useSurface' -import { useFigures } from '@/hooks/useFigures' -import { calculateExportDimensions } from '@/lib/domain/export' -import { getTotalDimensions } from '@/lib/domain/surface' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { Download, AlertCircle, ArrowLeft, Settings2 } from 'lucide-react' -import Link from 'next/link' +import { use } from 'react'; +import { useSurface } from '@/hooks/useSurface'; +import { useFigures } from '@/hooks/useFigures'; +import { calculateExportDimensions } from '@/lib/domain/export'; +import { getTotalDimensions } from '@/lib/domain/surface'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Download, AlertCircle, ArrowLeft, Settings2 } from 'lucide-react'; +import Link from 'next/link'; export default function ExportPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const { data: surface } = useSurface(id) - const { data: figures } = useFigures(id) + const { id } = use(params); + const { data: surface } = useSurface(id); + const { data: figures } = useFigures(id); if (!surface) { return ( @@ -29,14 +29,18 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> - ) + ); } - const exportDims = calculateExportDimensions(surface.panels, surface.dpi_target, surface.bleed_mm) - const { width_cm, height_cm } = getTotalDimensions(surface.panels) - const panelCount = surface.panels.length - const styledCount = figures?.filter(f => f.styled_url).length ?? 0 - const totalCount = figures?.length ?? 0 + const exportDims = calculateExportDimensions( + surface.panels, + surface.dpi_target, + surface.bleed_mm, + ); + const { width_cm, height_cm } = getTotalDimensions(surface.panels); + const panelCount = surface.panels.length; + const styledCount = figures?.filter((f) => f.styled_url).length ?? 0; + const totalCount = figures?.length ?? 0; return (
@@ -50,16 +54,23 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> {/* Summary */}
-

Project Summary

+

+ Project Summary +

Surface - {width_cm.toFixed(1)} x {height_cm.toFixed(1)} cm ({surface.panels.length} panel{surface.panels.length > 1 ? 's' : ''}) + + {width_cm.toFixed(1)} x {height_cm.toFixed(1)} cm ({surface.panels.length} panel + {surface.panels.length > 1 ? 's' : ''}) +
Figures - {styledCount} styled / {totalCount} total + + {styledCount} styled / {totalCount} total +
Dead zones @@ -70,7 +81,9 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> {/* Resolution — owned by the surface, so the file matches the spec that was signed off */}
- +

{surface.dpi_target} DPI

@@ -89,19 +102,25 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> {/* Export dimensions */}
-

Output Dimensions

+

+ Output Dimensions +

Full artwork - {exportDims.total_width_px} x {exportDims.total_height_px} px + + {exportDims.total_width_px} x {exportDims.total_height_px} px +
- {exportDims.panels.map(panel => ( + {exportDims.panels.map((panel) => (
{exportDims.panels.length > 1 ? `Panel ${panel.index + 1} file` : 'File'} - {panel.width_px} x {panel.height_px} px + + {panel.width_px} x {panel.height_px} px +
))}
@@ -115,7 +134,8 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }>

- {totalCount - styledCount} figure(s) don't have styled versions yet. They will use the original photo. + {totalCount - styledCount} figure(s) don't have styled versions yet. They will use + the original photo.

)} @@ -123,7 +143,8 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> @@ -133,5 +154,5 @@ export default function ExportPage({ params }: { params: Promise<{ id: string }> : `The Compose toolbar downloads the artwork at ${surface.dpi_target} DPI with ${surface.bleed_mm}mm bleed and no seam or dead-zone guides.`}

- ) + ); } diff --git a/app/src/app/project/[id]/figures/page.tsx b/app/src/app/project/[id]/figures/page.tsx index fee3463..907d274 100644 --- a/app/src/app/project/[id]/figures/page.tsx +++ b/app/src/app/project/[id]/figures/page.tsx @@ -1,17 +1,17 @@ -'use client' +'use client'; -import { use } from 'react' -import Link from 'next/link' -import { useFigures } from '@/hooks/useFigures' -import { FigureUploader } from '@/components/figures/FigureUploader' -import { FigureCard } from '@/components/figures/FigureCard' -import { Skeleton } from '@/components/ui/skeleton' -import { Button } from '@/components/ui/button' -import { ArrowRight, Lightbulb } from 'lucide-react' +import { use } from 'react'; +import Link from 'next/link'; +import { useFigures } from '@/hooks/useFigures'; +import { FigureUploader } from '@/components/figures/FigureUploader'; +import { FigureCard } from '@/components/figures/FigureCard'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { ArrowRight, Lightbulb } from 'lucide-react'; export default function FiguresPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const { data: figures, isLoading } = useFigures(id) + const { id } = use(params); + const { data: figures, isLoading } = useFigures(id); return (
@@ -36,7 +36,9 @@ export default function FiguresPage({ params }: { params: Promise<{ id: string }
  • Upload the original photo of each person or group
  • Choose an art style in the next step
  • Use an AI tool (Grok, Midjourney) to generate a styled version
  • -
  • Upload the styled version using the "Upload styled" button on each card
  • +
  • + Upload the styled version using the "Upload styled" button on each card +
  • Compose all styled figures together on the canvas
  • @@ -45,11 +47,13 @@ export default function FiguresPage({ params }: { params: Promise<{ id: string } {/* Figures list */} {isLoading ? (
    - {[...Array(3)].map((_, i) => )} + {[...Array(3)].map((_, i) => ( + + ))}
    ) : figures?.length ? (
    - {figures.map(figure => ( + {figures.map((figure) => ( ))}
    @@ -71,5 +75,5 @@ export default function FiguresPage({ params }: { params: Promise<{ id: string }
    )}
    - ) + ); } diff --git a/app/src/app/project/[id]/layout.tsx b/app/src/app/project/[id]/layout.tsx index 0147f3a..83a0c9b 100644 --- a/app/src/app/project/[id]/layout.tsx +++ b/app/src/app/project/[id]/layout.tsx @@ -1,21 +1,19 @@ -import { AppShell } from '@/components/layout/AppShell' -import { ProjectStepNav } from '@/components/layout/ProjectStepNav' +import { AppShell } from '@/components/layout/AppShell'; +import { ProjectStepNav } from '@/components/layout/ProjectStepNav'; export default async function ProjectLayout({ children, params, }: { - children: React.ReactNode - params: Promise<{ id: string }> + children: React.ReactNode; + params: Promise<{ id: string }>; }) { - const { id } = await params + const { id } = await params; return ( -
    - {children} -
    +
    {children}
    - ) + ); } diff --git a/app/src/app/project/[id]/page.tsx b/app/src/app/project/[id]/page.tsx index 52dacc0..f6bc129 100644 --- a/app/src/app/project/[id]/page.tsx +++ b/app/src/app/project/[id]/page.tsx @@ -1,10 +1,6 @@ -import { redirect } from 'next/navigation' +import { redirect } from 'next/navigation'; -export default async function ProjectPage({ - params, -}: { - params: Promise<{ id: string }> -}) { - const { id } = await params - redirect(`/project/${id}/figures`) +export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + redirect(`/project/${id}/figures`); } diff --git a/app/src/app/project/[id]/style/page.tsx b/app/src/app/project/[id]/style/page.tsx index 2634ed3..4f3808b 100644 --- a/app/src/app/project/[id]/style/page.tsx +++ b/app/src/app/project/[id]/style/page.tsx @@ -1,20 +1,20 @@ -'use client' +'use client'; -import { use } from 'react' -import Link from 'next/link' -import { useProject, useUpdateProject } from '@/hooks/useProject' -import { useStyles } from '@/hooks/useStyles' -import { StyleGallery } from '@/components/styles/StyleGallery' -import { Skeleton } from '@/components/ui/skeleton' -import { Button } from '@/components/ui/button' -import { ArrowRight } from 'lucide-react' -import { toast } from 'sonner' +import { use } from 'react'; +import Link from 'next/link'; +import { useProject, useUpdateProject } from '@/hooks/useProject'; +import { useStyles } from '@/hooks/useStyles'; +import { StyleGallery } from '@/components/styles/StyleGallery'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { ArrowRight } from 'lucide-react'; +import { toast } from 'sonner'; export default function StylePage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const { data: project } = useProject(id) - const { data: styles, isLoading } = useStyles() - const updateProject = useUpdateProject(id) + const { id } = use(params); + const { data: project } = useProject(id); + const { data: styles, isLoading } = useStyles(); + const updateProject = useUpdateProject(id); function handleSelect(styleId: string) { updateProject.mutate( @@ -22,8 +22,8 @@ export default function StylePage({ params }: { params: Promise<{ id: string }> { onSuccess: () => toast.success('Style selected'), onError: (err) => toast.error(err.message), - } - ) + }, + ); } return ( @@ -37,7 +37,9 @@ export default function StylePage({ params }: { params: Promise<{ id: string }> {isLoading ? (
    - {[...Array(6)].map((_, i) => )} + {[...Array(6)].map((_, i) => ( + + ))}
    ) : styles ? (
    )}
    - ) + ); } diff --git a/app/src/app/project/[id]/surface/page.tsx b/app/src/app/project/[id]/surface/page.tsx index 8c91f81..4d9c431 100644 --- a/app/src/app/project/[id]/surface/page.tsx +++ b/app/src/app/project/[id]/surface/page.tsx @@ -1,85 +1,85 @@ -'use client' +'use client'; -import { use, useState } from 'react' -import { useSurface, useUpsertSurface } from '@/hooks/useSurface' -import { SURFACE_PRESETS, type SurfacePreset } from '@/lib/config/surface-presets' -import { getTotalDimensions, getSeamPositionsFromPanels } from '@/lib/domain/surface' -import Link from 'next/link' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Badge } from '@/components/ui/badge' -import { cn } from '@/lib/utils' -import { Check, Plus, X, ArrowRight } from 'lucide-react' -import { toast } from 'sonner' -import type { Panel, DeadZone, SurfaceType } from '@/types/database' +import { use, useState } from 'react'; +import { useSurface, useUpsertSurface } from '@/hooks/useSurface'; +import { SURFACE_PRESETS, type SurfacePreset } from '@/lib/config/surface-presets'; +import { getTotalDimensions, getSeamPositionsFromPanels } from '@/lib/domain/surface'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { Check, Plus, X, ArrowRight } from 'lucide-react'; +import { toast } from 'sonner'; +import type { Panel, DeadZone, SurfaceType } from '@/types/database'; export default function SurfacePage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const { data: existingSurface } = useSurface(id) - const upsertSurface = useUpsertSurface(id) + const { id } = use(params); + const { data: existingSurface } = useSurface(id); + const upsertSurface = useUpsertSurface(id); - const [selectedPreset, setSelectedPreset] = useState('custom') - const [surfaceType, setSurfaceType] = useState('custom') - const [panels, setPanels] = useState([{ width_cm: 100, height_cm: 100 }]) - const [deadZones, setDeadZones] = useState([]) - const [dpiTarget, setDpiTarget] = useState(200) - const [bleedMm, setBleedMm] = useState(3) + const [selectedPreset, setSelectedPreset] = useState('custom'); + const [surfaceType, setSurfaceType] = useState('custom'); + const [panels, setPanels] = useState([{ width_cm: 100, height_cm: 100 }]); + const [deadZones, setDeadZones] = useState([]); + const [dpiTarget, setDpiTarget] = useState(200); + const [bleedMm, setBleedMm] = useState(3); // Hydrate the form once the saved surface loads. Adjusting state during render // against a remembered previous value (React "you might not need an effect") // avoids the extra render pass an effect-driven setState would cause. - const [hydratedSurface, setHydratedSurface] = useState(existingSurface) + const [hydratedSurface, setHydratedSurface] = useState(existingSurface); if (existingSurface && existingSurface !== hydratedSurface) { - setHydratedSurface(existingSurface) - setPanels(existingSurface.panels) - setDeadZones(existingSurface.dead_zones) - setDpiTarget(existingSurface.dpi_target) - setBleedMm(existingSurface.bleed_mm) - setSurfaceType(existingSurface.type) + setHydratedSurface(existingSurface); + setPanels(existingSurface.panels); + setDeadZones(existingSurface.dead_zones); + setDpiTarget(existingSurface.dpi_target); + setBleedMm(existingSurface.bleed_mm); + setSurfaceType(existingSurface.type); } function applyPreset(preset: SurfacePreset) { - setSelectedPreset(preset.id) - setSurfaceType(preset.type) - setPanels([...preset.panels]) - setDeadZones([...preset.dead_zones]) - setDpiTarget(preset.dpi_target) - setBleedMm(preset.bleed_mm) + setSelectedPreset(preset.id); + setSurfaceType(preset.type); + setPanels([...preset.panels]); + setDeadZones([...preset.dead_zones]); + setDpiTarget(preset.dpi_target); + setBleedMm(preset.bleed_mm); } function updatePanel(index: number, field: keyof Panel, value: number) { - const updated = [...panels] - updated[index] = { ...updated[index], [field]: value } - setPanels(updated) + const updated = [...panels]; + updated[index] = { ...updated[index], [field]: value }; + setPanels(updated); } function addPanel() { - setPanels([...panels, { width_cm: 100, height_cm: panels[0]?.height_cm ?? 100 }]) + setPanels([...panels, { width_cm: 100, height_cm: panels[0]?.height_cm ?? 100 }]); } function removePanel(index: number) { - if (panels.length <= 1) return - setPanels(panels.filter((_, i) => i !== index)) + if (panels.length <= 1) return; + setPanels(panels.filter((_, i) => i !== index)); } function addDeadZone() { - setDeadZones([...deadZones, { x_cm: 0, y_cm: 0, width_cm: 40, height_cm: 40, reason: '' }]) + setDeadZones([...deadZones, { x_cm: 0, y_cm: 0, width_cm: 40, height_cm: 40, reason: '' }]); } function updateDeadZone(index: number, field: keyof DeadZone, value: string | number) { - const updated = [...deadZones] - updated[index] = { ...updated[index], [field]: value } - setDeadZones(updated) + const updated = [...deadZones]; + updated[index] = { ...updated[index], [field]: value }; + setDeadZones(updated); } function removeDeadZone(index: number) { - setDeadZones(deadZones.filter((_, i) => i !== index)) + setDeadZones(deadZones.filter((_, i) => i !== index)); } function handleSave() { - const seams = getSeamPositionsFromPanels(panels) + const seams = getSeamPositionsFromPanels(panels); upsertSurface.mutate( { project_id: id, @@ -93,12 +93,12 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string } { onSuccess: () => toast.success('Surface saved'), onError: (err) => toast.error(err.message), - } - ) + }, + ); } - const { width_cm, height_cm } = getTotalDimensions(panels) - const scale = Math.min(500 / width_cm, 300 / height_cm, 2) + const { width_cm, height_cm } = getTotalDimensions(panels); + const scale = Math.min(500 / width_cm, 300 / height_cm, 2); return (
    @@ -115,22 +115,20 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string } Presets
    - {SURFACE_PRESETS.map(preset => ( + {SURFACE_PRESETS.map((preset) => ( ))}
    @@ -152,14 +150,17 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string }
    {panels.map((panel, i) => ( -
    +
    updatePanel(i, 'width_cm', parseFloat(e.target.value) || 0)} + onChange={(e) => updatePanel(i, 'width_cm', parseFloat(e.target.value) || 0)} className="sm:w-28 h-9" />
    @@ -168,15 +169,22 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string } updatePanel(i, 'height_cm', parseFloat(e.target.value) || 0)} + onChange={(e) => updatePanel(i, 'height_cm', parseFloat(e.target.value) || 0)} className="sm:w-28 h-9" />
    - Panel {i + 1} + + Panel {i + 1} + {panels.length > 1 && ( - )} @@ -192,38 +200,73 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string }

    Dead Zones

    -

    Areas blocked by fixtures (shower head, faucet, etc.)

    +

    + Areas blocked by fixtures (shower head, faucet, etc.) +

    {deadZones.map((zone, i) => ( -
    +
    - updateDeadZone(i, 'x_cm', parseFloat(e.target.value) || 0)} className="h-9" /> + updateDeadZone(i, 'x_cm', parseFloat(e.target.value) || 0)} + className="h-9" + />
    - updateDeadZone(i, 'y_cm', parseFloat(e.target.value) || 0)} className="h-9" /> + updateDeadZone(i, 'y_cm', parseFloat(e.target.value) || 0)} + className="h-9" + />
    - updateDeadZone(i, 'width_cm', parseFloat(e.target.value) || 0)} className="h-9" /> + updateDeadZone(i, 'width_cm', parseFloat(e.target.value) || 0)} + className="h-9" + />
    - updateDeadZone(i, 'height_cm', parseFloat(e.target.value) || 0)} className="h-9" /> + updateDeadZone(i, 'height_cm', parseFloat(e.target.value) || 0)} + className="h-9" + />
    - updateDeadZone(i, 'reason', e.target.value)} placeholder="e.g., Shower fixture" className="h-9" /> + updateDeadZone(i, 'reason', e.target.value)} + placeholder="e.g., Shower fixture" + className="h-9" + />
    -
    @@ -237,18 +280,30 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string }
    - setDpiTarget(parseInt(e.target.value) || 200)} className="sm:w-28 h-9" /> + setDpiTarget(parseInt(e.target.value) || 200)} + className="sm:w-28 h-9" + />
    - setBleedMm(parseFloat(e.target.value) || 0)} className="sm:w-28 h-9" /> + setBleedMm(parseFloat(e.target.value) || 0)} + className="sm:w-28 h-9" + />
    {/* Visual Preview */}
    -

    Preview

    +

    + Preview +

    @@ -260,16 +315,17 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string } style={{ width: panel.width_cm * scale, height: panel.height_cm * scale, - borderRight: i < panels.length - 1 ? '2px dashed hsl(var(--destructive))' : undefined, + borderRight: + i < panels.length - 1 ? '2px dashed hsl(var(--destructive))' : undefined, }} > P{i + 1}: {panel.width_cm}x{panel.height_cm} {deadZones.map((zone, zi) => { - const panelX = panels.slice(0, i).reduce((s, p) => s + p.width_cm, 0) - const zoneRelX = zone.x_cm - panelX - if (zoneRelX < 0 || zoneRelX >= panel.width_cm) return null + const panelX = panels.slice(0, i).reduce((s, p) => s + p.width_cm, 0); + const zoneRelX = zone.x_cm - panelX; + if (zoneRelX < 0 || zoneRelX >= panel.width_cm) return null; return (
    {zone.reason}
    - ) + ); })}
    - ) + ); })}
    - {existingSurface && ( @@ -305,5 +370,5 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string } )}
    - ) + ); } diff --git a/app/src/app/projects/new/page.tsx b/app/src/app/projects/new/page.tsx index e7c76ef..875af7f 100644 --- a/app/src/app/projects/new/page.tsx +++ b/app/src/app/projects/new/page.tsx @@ -1,24 +1,24 @@ -'use client' +'use client'; -import { useState } from 'react' -import { useRouter } from 'next/navigation' -import { useCreateProject } from '@/hooks/useProjects' -import { AppShell } from '@/components/layout/AppShell' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { toast } from 'sonner' +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useCreateProject } from '@/hooks/useProjects'; +import { AppShell } from '@/components/layout/AppShell'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; export default function NewProjectPage() { - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [sceneDescription, setSceneDescription] = useState('') - const router = useRouter() - const createProject = useCreateProject() + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [sceneDescription, setSceneDescription] = useState(''); + const router = useRouter(); + const createProject = useCreateProject(); async function handleSubmit(e: React.FormEvent) { - e.preventDefault() + e.preventDefault(); createProject.mutate( { name, @@ -27,12 +27,12 @@ export default function NewProjectPage() { }, { onSuccess: (project) => { - toast.success('Project created') - router.push(`/project/${project.id}/figures`) + toast.success('Project created'); + router.push(`/project/${project.id}/figures`); }, onError: (err) => toast.error(err.message), - } - ) + }, + ); } return ( @@ -48,43 +48,58 @@ export default function NewProjectPage() {
    - + setName(e.target.value)} + onChange={(e) => setName(e.target.value)} placeholder="e.g., Amphicar Lake Garda" className="h-11 bg-transparent border-white/[0.08]" required />
    - +