diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..2c08405
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
+d666eedc19e5bbd1ce6b7436e95719e21f826163 # prettier, 85 files
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/.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/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() {
@@ -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.
-
+
{user ? 'Go to Projects' : 'Get Started'}
-
+
How it works
@@ -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) => (
@@ -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.
Go to Figures
- )
+ );
}
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 */}
-
Resolution
+
+ Resolution
+
{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 }>
- Export {panelCount > 1 ? `${panelCount} panels` : 'PNG'} at {surface.dpi_target} DPI in Compose
+ Export {panelCount > 1 ? `${panelCount} panels` : 'PNG'} at {surface.dpi_target} DPI in
+ Compose
@@ -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) => (
applyPreset(preset)}
>
{preset.name}
{preset.description}
- {selectedPreset === preset.id && (
-
- )}
+ {selectedPreset === preset.id && }
))}
@@ -152,14 +150,17 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string }
{panels.map((panel, i) => (
-
+
Width (cm)
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 && (
-
removePanel(i)}>
+ removePanel(i)}
+ >
)}
@@ -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.)
+
Add Zone
{deadZones.map((zone, i) => (
-
+
X (cm)
- updateDeadZone(i, 'x_cm', parseFloat(e.target.value) || 0)} className="h-9" />
+ updateDeadZone(i, 'x_cm', parseFloat(e.target.value) || 0)}
+ className="h-9"
+ />
Y (cm)
- updateDeadZone(i, 'y_cm', parseFloat(e.target.value) || 0)} className="h-9" />
+ updateDeadZone(i, 'y_cm', parseFloat(e.target.value) || 0)}
+ className="h-9"
+ />
Width
- updateDeadZone(i, 'width_cm', parseFloat(e.target.value) || 0)} className="h-9" />
+ updateDeadZone(i, 'width_cm', parseFloat(e.target.value) || 0)}
+ className="h-9"
+ />
Height
- updateDeadZone(i, 'height_cm', parseFloat(e.target.value) || 0)} className="h-9" />
+ updateDeadZone(i, 'height_cm', parseFloat(e.target.value) || 0)}
+ className="h-9"
+ />
@@ -237,18 +280,30 @@ export default function SurfacePage({ params }: { params: Promise<{ id: string }
{/* 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}
- )
+ );
})}
- )
+ );
})}
-
- {upsertSurface.isPending ? 'Saving...' : existingSurface ? 'Update Surface' : 'Save Surface'}
+
+ {upsertSurface.isPending
+ ? 'Saving...'
+ : existingSurface
+ ? 'Update Surface'
+ : 'Save Surface'}
{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() {
- )
+ );
}
diff --git a/app/src/app/projects/page.tsx b/app/src/app/projects/page.tsx
index 3a15b72..ee759d8 100644
--- a/app/src/app/projects/page.tsx
+++ b/app/src/app/projects/page.tsx
@@ -1,24 +1,24 @@
-'use client'
+'use client';
-import Link from 'next/link'
-import { useProjects, useDeleteProject } from '@/hooks/useProjects'
-import { AppShell } from '@/components/layout/AppShell'
-import { ProjectCard } from '@/components/projects/ProjectCard'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Plus, Sparkles } from 'lucide-react'
-import { toast } from 'sonner'
+import Link from 'next/link';
+import { useProjects, useDeleteProject } from '@/hooks/useProjects';
+import { AppShell } from '@/components/layout/AppShell';
+import { ProjectCard } from '@/components/projects/ProjectCard';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Plus, Sparkles } from 'lucide-react';
+import { toast } from 'sonner';
export default function ProjectsPage() {
- const { data: projects, isLoading } = useProjects()
- const deleteProject = useDeleteProject()
+ const { data: projects, isLoading } = useProjects();
+ const deleteProject = useDeleteProject();
function handleDelete(id: string) {
- if (!confirm('Delete this project?')) return
+ if (!confirm('Delete this project?')) return;
deleteProject.mutate(id, {
onSuccess: () => toast.success('Project deleted'),
onError: (err) => toast.error(err.message),
- })
+ });
}
return (
@@ -29,9 +29,7 @@ export default function ProjectsPage() {
-
- Your Projects
-
+
Your Projects
Artwork compositions in progress
@@ -75,8 +73,8 @@ export default function ProjectsPage() {
No projects yet
- Create your first artwork composition. Upload photos of real people,
- choose an art style, and bring them together in one scene.
+ Create your first artwork composition. Upload photos of real people, choose an art
+ style, and bring them together in one scene.
@@ -90,5 +88,5 @@ export default function ProjectsPage() {
)}
- )
+ );
}
diff --git a/app/src/app/register/page.tsx b/app/src/app/register/page.tsx
index 8531d97..e0fa936 100644
--- a/app/src/app/register/page.tsx
+++ b/app/src/app/register/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 RegisterPage() {
- 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 handleRegister(e: React.FormEvent) {
- e.preventDefault()
- setLoading(true)
- setError('')
+ e.preventDefault();
+ setLoading(true);
+ setError('');
- const { error } = await supabase.auth.signUp({ email, password })
+ const { error } = await supabase.auth.signUp({ 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,24 +48,28 @@ export default function RegisterPage() {
- )
+ );
}
diff --git a/app/src/components/compose/CanvasToolbar.tsx b/app/src/components/compose/CanvasToolbar.tsx
index f2c4d12..53a9684 100644
--- a/app/src/components/compose/CanvasToolbar.tsx
+++ b/app/src/components/compose/CanvasToolbar.tsx
@@ -1,70 +1,80 @@
-'use client'
-
-import { useRef, useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { ArrowUp, ArrowDown, Download, ImagePlus } from 'lucide-react'
-import { useUpdateFigure } from '@/hooks/useFigures'
-import { getPanelExportRegions, resolveExportScale } from '@/lib/domain/export'
-import { renderPanels, type PanelFile } from '@/lib/export/stage-export'
-import { toast } from 'sonner'
-import type { Figure, Surface } from '@/types/database'
-import type Konva from 'konva'
+'use client';
+
+import { useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { ArrowUp, ArrowDown, Download, ImagePlus } from 'lucide-react';
+import { useUpdateFigure } from '@/hooks/useFigures';
+import { getPanelExportRegions, resolveExportScale } from '@/lib/domain/export';
+import { renderPanels, type PanelFile } from '@/lib/export/stage-export';
+import { toast } from 'sonner';
+import type { Figure, Surface } from '@/types/database';
+import type Konva from 'konva';
interface CanvasToolbarProps {
- stageRef: React.RefObject
+ stageRef: React.RefObject;
/** Seams, dead zones and buffers — editor guides that must never be printed. */
- overlayRef: React.RefObject
- selectedId: string | null
- figures: Figure[]
- surface: Surface
- projectId: string
+ overlayRef: React.RefObject;
+ selectedId: string | null;
+ figures: Figure[];
+ surface: Surface;
+ projectId: string;
/** The saved background has not painted onto the stage yet. */
- backgroundPending: boolean
- onBackgroundUpload: (file: File) => void
+ backgroundPending: boolean;
+ onBackgroundUpload: (file: File) => void;
}
/** How far to back off, and how often, when the browser refuses an allocation
* we thought was legal — a 2m wall at 200 DPI is ~230M pixels, and the ceiling
* that actually applies depends on the browser and the machine's memory. */
-const BROWSER_RETRY_FACTOR = 0.6
-const MAX_EXPORT_ATTEMPTS = 4
+const BROWSER_RETRY_FACTOR = 0.6;
+const MAX_EXPORT_ATTEMPTS = 4;
/** Browsers drop downloads fired back to back; give each one room to start. */
-const DOWNLOAD_GAP_MS = 400
-
-export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surface, projectId, backgroundPending, onBackgroundUpload }: CanvasToolbarProps) {
- const bgInputRef = useRef(null)
- const [isExporting, setIsExporting] = useState(false)
- const updateFigure = useUpdateFigure(projectId)
- const selectedFigure = figures.find(f => f.id === selectedId)
+const DOWNLOAD_GAP_MS = 400;
+
+export function CanvasToolbar({
+ stageRef,
+ overlayRef,
+ selectedId,
+ figures,
+ surface,
+ projectId,
+ backgroundPending,
+ onBackgroundUpload,
+}: CanvasToolbarProps) {
+ const bgInputRef = useRef(null);
+ const [isExporting, setIsExporting] = useState(false);
+ const updateFigure = useUpdateFigure(projectId);
+ const selectedFigure = figures.find((f) => f.id === selectedId);
function moveLayer(direction: 'up' | 'down') {
- if (!selectedFigure) return
- const newDepth = direction === 'up' ? selectedFigure.z_depth + 1 : Math.max(0, selectedFigure.z_depth - 1)
- updateFigure.mutate({ id: selectedFigure.id, data: { z_depth: newDepth } })
+ if (!selectedFigure) return;
+ const newDepth =
+ direction === 'up' ? selectedFigure.z_depth + 1 : Math.max(0, selectedFigure.z_depth - 1);
+ updateFigure.mutate({ id: selectedFigure.id, data: { z_depth: newDepth } });
}
function downloadPanel(file: PanelFile, panelCount: number, dpi: number) {
- const url = URL.createObjectURL(file.blob)
+ const url = URL.createObjectURL(file.blob);
const name =
panelCount > 1
? `printcraft-panel-${file.index + 1}of${panelCount}-${dpi}dpi.png`
- : `printcraft-composition-${dpi}dpi.png`
- const link = document.createElement('a')
- link.download = name
- link.href = url
- link.click()
- URL.revokeObjectURL(url)
+ : `printcraft-composition-${dpi}dpi.png`;
+ const link = document.createElement('a');
+ link.download = name;
+ link.href = url;
+ link.click();
+ URL.revokeObjectURL(url);
}
async function handleExportPng() {
- const stage = stageRef.current
- if (!stage || isExporting) return
+ const stage = stageRef.current;
+ if (!stage || isExporting) return;
// Exporting now would print the bare canvas and still report success —
// the scene would be missing from a file nobody re-checks before printing.
if (backgroundPending) {
- toast.error('The background is still loading — exporting now would print without the scene.')
- return
+ toast.error('The background is still loading — exporting now would print without the scene.');
+ return;
}
// One sheet per physical panel — that is what gets printed and trimmed.
@@ -74,100 +84,135 @@ export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surfa
dpi: surface.dpi_target,
stageWidth: stage.width(),
stageHeight: stage.height(),
- })
+ });
if (regions.length === 0) {
- toast.error('The canvas has not been measured yet — try again in a moment.')
- return
+ toast.error('The canvas has not been measured yet — try again in a moment.');
+ return;
}
// Every sheet has to come out at the same DPI, so the largest panel sets
// the scale for all of them.
const binding = regions.reduce((widest, region) =>
- Math.max(region.stage.width, region.stage.height) > Math.max(widest.stage.width, widest.stage.height)
+ Math.max(region.stage.width, region.stage.height) >
+ Math.max(widest.stage.width, widest.stage.height)
? region
- : widest
- )
+ : widest,
+ );
const scaleArgs = {
canvasWidth: binding.stage.width,
canvasHeight: binding.stage.height,
targetWidthPx: binding.output.width_px,
targetDpi: surface.dpi_target,
- }
+ };
- setIsExporting(true)
+ setIsExporting(true);
// The guide layer is editor chrome, not artwork — a red seam line printed
// onto a 2m glass wall ruins the piece (Ground Truth #5). Konva's stage
// export skips layers that are not visible.
- overlayRef.current?.hide()
+ overlayRef.current?.hide();
try {
- let scale = resolveExportScale(scaleArgs)
- let files: PanelFile[] | null = null
+ let scale = resolveExportScale(scaleArgs);
+ let files: PanelFile[] | null = null;
for (let attempt = 0; attempt < MAX_EXPORT_ATTEMPTS && !files; attempt++) {
if (attempt > 0) {
scale = resolveExportScale({
...scaleArgs,
maxPixelRatio: scale.pixelRatio * BROWSER_RETRY_FACTOR,
- })
+ });
}
- files = await renderPanels(stage, regions, scale.pixelRatio)
+ files = await renderPanels(stage, regions, scale.pixelRatio);
}
if (!files) {
- toast.error('Export failed — your browser could not allocate a canvas this large.')
- return
+ toast.error('Export failed — your browser could not allocate a canvas this large.');
+ return;
}
for (const [i, file] of files.entries()) {
- if (i > 0) await new Promise(resolve => setTimeout(resolve, DOWNLOAD_GAP_MS))
- downloadPanel(file, files.length, scale.dpi)
+ if (i > 0) await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_GAP_MS));
+ downloadPanel(file, files.length, scale.dpi);
}
if (scale.limitedBy !== 'none') {
toast.warning(
- `Exported at ${scale.dpi} DPI — under the ${surface.dpi_target} DPI target because your browser caps canvas size.`
- )
+ `Exported at ${scale.dpi} DPI — under the ${surface.dpi_target} DPI target because your browser caps canvas size.`,
+ );
} else if (files.length > 1) {
- toast.success(`Print-ready — ${files.length} panel files at ${scale.dpi} DPI, bleed included`)
+ toast.success(
+ `Print-ready — ${files.length} panel files at ${scale.dpi} DPI, bleed included`,
+ );
} else {
- const size = `${Math.round(binding.stage.width * scale.pixelRatio)} x ${Math.round(binding.stage.height * scale.pixelRatio)} px`
- toast.success(`Print-ready PNG — ${size} at ${scale.dpi} DPI`)
+ const size = `${Math.round(binding.stage.width * scale.pixelRatio)} x ${Math.round(binding.stage.height * scale.pixelRatio)} px`;
+ toast.success(`Print-ready PNG — ${size} at ${scale.dpi} DPI`);
}
} finally {
- overlayRef.current?.show()
- setIsExporting(false)
+ overlayRef.current?.show();
+ setIsExporting(false);
}
}
function handleBgFileChange(e: React.ChangeEvent) {
- const file = e.target.files?.[0]
- if (file) onBackgroundUpload(file)
+ const file = e.target.files?.[0];
+ if (file) onBackgroundUpload(file);
}
return (
- )
+ );
}
diff --git a/app/src/components/compose/CompositionCanvas.tsx b/app/src/components/compose/CompositionCanvas.tsx
index 93bd394..9724e16 100644
--- a/app/src/components/compose/CompositionCanvas.tsx
+++ b/app/src/components/compose/CompositionCanvas.tsx
@@ -1,133 +1,141 @@
-'use client'
+'use client';
-import { useRef, useState, useEffect, useCallback } from 'react'
-import { Stage, Layer, Rect, Line } from 'react-konva'
-import { FigureLayer } from './FigureLayer'
-import { CanvasToolbar } from './CanvasToolbar'
-import { getTotalDimensions, SEAM_BUFFER_CM } from '@/lib/domain/surface'
-import type { PlacementViolation } from '@/lib/domain/surface'
-import { useUpdateFigure } from '@/hooks/useFigures'
-import { useComposition, useSaveComposition } from '@/hooks/useComposition'
-import { useSupabaseUpload } from '@/hooks/useSupabaseUpload'
-import { getImageUrl } from '@/lib/supabase/storage'
-import { cn } from '@/lib/utils'
-import { toast } from 'sonner'
-import type { Figure, Surface } from '@/types/database'
-import type Konva from 'konva'
+import { useRef, useState, useEffect, useCallback } from 'react';
+import { Stage, Layer, Rect, Line } from 'react-konva';
+import { FigureLayer } from './FigureLayer';
+import { CanvasToolbar } from './CanvasToolbar';
+import { getTotalDimensions, SEAM_BUFFER_CM } from '@/lib/domain/surface';
+import type { PlacementViolation } from '@/lib/domain/surface';
+import { useUpdateFigure } from '@/hooks/useFigures';
+import { useComposition, useSaveComposition } from '@/hooks/useComposition';
+import { useSupabaseUpload } from '@/hooks/useSupabaseUpload';
+import { getImageUrl } from '@/lib/supabase/storage';
+import { cn } from '@/lib/utils';
+import { toast } from 'sonner';
+import type { Figure, Surface } from '@/types/database';
+import type Konva from 'konva';
interface CompositionCanvasProps {
- projectId: string
- figures: Figure[]
- surface: Surface
+ projectId: string;
+ figures: Figure[];
+ surface: Surface;
}
export function CompositionCanvas({ projectId, figures, surface }: CompositionCanvasProps) {
- const stageRef = useRef(null)
- const overlayRef = useRef(null)
- const containerRef = useRef(null)
- const [containerSize, setContainerSize] = useState({ width: 800, height: 600 })
- const [selectedId, setSelectedId] = useState(null)
- const [loadedBg, setLoadedBg] = useState<{ path: string; image: HTMLImageElement } | null>(null)
- const [bgUploading, setBgUploading] = useState(false)
- const updateFigure = useUpdateFigure(projectId)
+ const stageRef = useRef(null);
+ const overlayRef = useRef(null);
+ const containerRef = useRef(null);
+ const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
+ const [selectedId, setSelectedId] = useState(null);
+ const [loadedBg, setLoadedBg] = useState<{ path: string; image: HTMLImageElement } | null>(null);
+ const [bgUploading, setBgUploading] = useState(false);
+ const updateFigure = useUpdateFigure(projectId);
// The background is the scene itself. Held only in memory it vanished on
// refresh, and the export then silently printed a black wall.
- const { data: composition, isLoading: compositionLoading } = useComposition(projectId)
- const saveComposition = useSaveComposition(projectId)
- const { upload } = useSupabaseUpload(projectId)
- const backgroundPath = composition?.background_url ?? null
+ const { data: composition, isLoading: compositionLoading } = useComposition(projectId);
+ const saveComposition = useSaveComposition(projectId);
+ const { upload } = useSupabaseUpload(projectId);
+ const backgroundPath = composition?.background_url ?? null;
- const { width_cm, height_cm } = getTotalDimensions(surface.panels)
- const aspectRatio = width_cm / height_cm
+ const { width_cm, height_cm } = getTotalDimensions(surface.panels);
+ const aspectRatio = width_cm / height_cm;
// Fit canvas to container
useEffect(() => {
function resize() {
- if (!containerRef.current) return
- const w = containerRef.current.clientWidth
- const h = Math.min(w / aspectRatio, window.innerHeight - 200)
- setContainerSize({ width: w, height: h })
+ if (!containerRef.current) return;
+ const w = containerRef.current.clientWidth;
+ const h = Math.min(w / aspectRatio, window.innerHeight - 200);
+ setContainerSize({ width: w, height: h });
}
- resize()
- window.addEventListener('resize', resize)
- return () => window.removeEventListener('resize', resize)
- }, [aspectRatio])
+ resize();
+ window.addEventListener('resize', resize);
+ return () => window.removeEventListener('resize', resize);
+ }, [aspectRatio]);
// Restore the saved background. crossOrigin is what keeps the Konva stage
// untainted, so the export can still read pixels back out of it.
useEffect(() => {
- if (!backgroundPath) return
- let cancelled = false
- const img = new window.Image()
- img.crossOrigin = 'anonymous'
+ if (!backgroundPath) return;
+ let cancelled = false;
+ const img = new window.Image();
+ img.crossOrigin = 'anonymous';
img.onload = () => {
- if (!cancelled) setLoadedBg({ path: backgroundPath, image: img })
- }
+ if (!cancelled) setLoadedBg({ path: backgroundPath, image: img });
+ };
img.onerror = () => {
- if (!cancelled) toast.error('The saved background could not be loaded.')
- }
- img.src = getImageUrl(backgroundPath)
+ if (!cancelled) toast.error('The saved background could not be loaded.');
+ };
+ img.src = getImageUrl(backgroundPath);
return () => {
- cancelled = true
- }
- }, [backgroundPath])
+ cancelled = true;
+ };
+ }, [backgroundPath]);
// Derived, not stored: a background that no longer matches the saved path is
// a stale image, and painting it would export a scene the project dropped.
- const bgImage = loadedBg?.path === backgroundPath ? loadedBg.image : null
+ const bgImage = loadedBg?.path === backgroundPath ? loadedBg.image : null;
const handleBackgroundUpload = useCallback(
async (file: File) => {
- setBgUploading(true)
- const result = await upload(file, 'backgrounds')
- setBgUploading(false)
+ setBgUploading(true);
+ const result = await upload(file, 'backgrounds');
+ setBgUploading(false);
if (!result) {
- toast.error('Background upload failed — nothing was saved.')
- return
+ toast.error('Background upload failed — nothing was saved.');
+ return;
}
// Only the background is stored here. Figure placement lives on the
// figures rows, and duplicating it into layout would give it two homes.
saveComposition.mutate(
{ project_id: projectId, background_url: result.path },
- { onError: (err) => toast.error(err.message) }
- )
+ { onError: (err) => toast.error(err.message) },
+ );
},
- [upload, saveComposition, projectId]
- )
+ [upload, saveComposition, projectId],
+ );
// Build image URLs (synchronous — public bucket)
- const imageUrls: Record = {}
+ const imageUrls: Record = {};
for (const fig of figures) {
- const path = fig.styled_url || fig.original_photo_url
- if (path) imageUrls[fig.id] = getImageUrl(path)
+ const path = fig.styled_url || fig.original_photo_url;
+ if (path) imageUrls[fig.id] = getImageUrl(path);
}
- const handleDragEnd = useCallback((figureId: string, normX: number, normY: number) => {
- updateFigure.mutate({ id: figureId, data: { position_x: normX, position_y: normY } })
- }, [updateFigure])
+ const handleDragEnd = useCallback(
+ (figureId: string, normX: number, normY: number) => {
+ updateFigure.mutate({ id: figureId, data: { position_x: normX, position_y: normY } });
+ },
+ [updateFigure],
+ );
- const handleScaleChange = useCallback((figureId: string, scale: number) => {
- updateFigure.mutate({ id: figureId, data: { scale } })
- }, [updateFigure])
+ const handleScaleChange = useCallback(
+ (figureId: string, scale: number) => {
+ updateFigure.mutate({ id: figureId, data: { scale } });
+ },
+ [updateFigure],
+ );
const handleBlocked = useCallback((violation: PlacementViolation) => {
toast.info(
violation.kind === 'dead-zone'
? `Kept clear of ${violation.reason} — artwork there would be covered up.`
- : 'Kept off the glass seam — a figure across the joint gets cut in half.'
- )
- }, [])
+ : 'Kept off the glass seam — a figure across the joint gets cut in half.',
+ );
+ }, []);
- const placedFigures = figures.filter(f => imageUrls[f.id])
+ const placedFigures = figures.filter((f) => imageUrls[f.id]);
return (
{/* Figure thumbnails sidebar — desktop only */}
-
Figures
- {figures.map(fig => {
- const url = imageUrls[fig.id]
- if (!url) return null
+
+ Figures
+
+ {figures.map((fig) => {
+ const url = imageUrls[fig.id];
+ if (!url) return null;
return (
setSelectedId(fig.id)}
>
@@ -144,7 +152,7 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa
{fig.label ?? 'Unnamed'}
- )
+ );
})}
@@ -159,16 +167,12 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa
width={containerSize.width}
height={containerSize.height}
onClick={(e) => {
- if (e.target === e.target.getStage()) setSelectedId(null)
+ if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
{/* Background */}
-
+
{bgImage && (
{placedFigures
.sort((a, b) => a.z_depth - b.z_depth)
- .map(figure => (
+ .map((figure) => (
{/* Seam lines */}
{surface.seam_positions.map((seam, i) => {
- const x = (seam.x_cm / width_cm) * containerSize.width
+ const x = (seam.x_cm / width_cm) * containerSize.width;
return (
- )
+ );
})}
{/* Dead zones */}
{surface.dead_zones.map((zone, i) => {
- const x = (zone.x_cm / width_cm) * containerSize.width
- const y = containerSize.height - ((zone.y_cm + zone.height_cm) / height_cm) * containerSize.height
- const w = (zone.width_cm / width_cm) * containerSize.width
- const h = (zone.height_cm / height_cm) * containerSize.height
+ const x = (zone.x_cm / width_cm) * containerSize.width;
+ const y =
+ containerSize.height -
+ ((zone.y_cm + zone.height_cm) / height_cm) * containerSize.height;
+ const w = (zone.width_cm / width_cm) * containerSize.width;
+ const h = (zone.height_cm / height_cm) * containerSize.height;
return (
- )
+ );
})}
{/* Seam buffer zones */}
{surface.seam_positions.map((seam, i) => {
- const x = ((seam.x_cm - SEAM_BUFFER_CM) / width_cm) * containerSize.width
- const w = ((SEAM_BUFFER_CM * 2) / width_cm) * containerSize.width
+ const x = ((seam.x_cm - SEAM_BUFFER_CM) / width_cm) * containerSize.width;
+ const w = ((SEAM_BUFFER_CM * 2) / width_cm) * containerSize.width;
return (
- )
+ );
})}
@@ -264,7 +270,9 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa
No figures placed yet
-
Upload styled figures, then drag them onto the canvas
+
+ Upload styled figures, then drag them onto the canvas
+
)}
@@ -279,13 +287,11 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa
figures={figures}
surface={surface}
projectId={projectId}
- backgroundPending={
- bgUploading || compositionLoading || (!!backgroundPath && !bgImage)
- }
+ backgroundPending={bgUploading || compositionLoading || (!!backgroundPath && !bgImage)}
onBackgroundUpload={handleBackgroundUpload}
/>
- )
+ );
}
diff --git a/app/src/components/compose/FigureLayer.tsx b/app/src/components/compose/FigureLayer.tsx
index 6004516..29854d6 100644
--- a/app/src/components/compose/FigureLayer.tsx
+++ b/app/src/components/compose/FigureLayer.tsx
@@ -1,27 +1,27 @@
-'use client'
+'use client';
-import { useRef, useEffect, useState } from 'react'
-import { Image as KonvaImage, Transformer } from 'react-konva'
-import { canvasRectToSurfaceCm, checkPlacement } from '@/lib/domain/surface'
-import type { PlacementViolation } from '@/lib/domain/surface'
-import type Konva from 'konva'
-import type { DeadZone, Figure, SeamPosition } from '@/types/database'
+import { useRef, useEffect, useState } from 'react';
+import { Image as KonvaImage, Transformer } from 'react-konva';
+import { canvasRectToSurfaceCm, checkPlacement } from '@/lib/domain/surface';
+import type { PlacementViolation } from '@/lib/domain/surface';
+import type Konva from 'konva';
+import type { DeadZone, Figure, SeamPosition } from '@/types/database';
interface FigureLayerProps {
- figure: Figure
- imageUrl: string
- canvasWidth: number
- canvasHeight: number
- totalWidthCm: number
- totalHeightCm: number
- deadZones: DeadZone[]
- seams: SeamPosition[]
- isSelected: boolean
- onSelect: () => void
- onDragEnd: (figureId: string, x: number, y: number) => void
- onScaleChange: (figureId: string, scale: number) => void
+ figure: Figure;
+ imageUrl: string;
+ canvasWidth: number;
+ canvasHeight: number;
+ totalWidthCm: number;
+ totalHeightCm: number;
+ deadZones: DeadZone[];
+ seams: SeamPosition[];
+ isSelected: boolean;
+ onSelect: () => void;
+ onDragEnd: (figureId: string, x: number, y: number) => void;
+ onScaleChange: (figureId: string, scale: number) => void;
/** Fired when a move or resize was refused, so the editor can say why. */
- onBlocked: (violation: PlacementViolation) => void
+ onBlocked: (violation: PlacementViolation) => void;
}
export function FigureLayer({
@@ -39,42 +39,46 @@ export function FigureLayer({
onScaleChange,
onBlocked,
}: FigureLayerProps) {
- const imageRef = useRef
(null)
- const trRef = useRef(null)
- const [image, setImage] = useState(null)
- const lastLegalPos = useRef<{ x: number; y: number } | null>(null)
- const blockedBy = useRef(null)
+ const imageRef = useRef(null);
+ const trRef = useRef(null);
+ const [image, setImage] = useState(null);
+ const lastLegalPos = useRef<{ x: number; y: number } | null>(null);
+ const blockedBy = useRef(null);
// A figure can already be sitting in a zone — the surface may have been
// edited after it was placed. Refusing to move it then would strand it.
- const escapingZone = useRef(false)
+ const escapingZone = useRef(false);
useEffect(() => {
- const img = new window.Image()
- img.crossOrigin = 'anonymous'
- img.onload = () => setImage(img)
- img.src = imageUrl
- }, [imageUrl])
+ const img = new window.Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => setImage(img);
+ img.src = imageUrl;
+ }, [imageUrl]);
useEffect(() => {
if (isSelected && trRef.current && imageRef.current) {
- trRef.current.nodes([imageRef.current])
- trRef.current.getLayer()?.batchDraw()
+ trRef.current.nodes([imageRef.current]);
+ trRef.current.getLayer()?.batchDraw();
}
- }, [isSelected])
+ }, [isSelected]);
- if (!image) return null
+ if (!image) return null;
// Position: normalized (0-1) -> canvas pixels
- const x = (figure.position_x ?? 0.5) * canvasWidth
- const y = (figure.position_y ?? 0.5) * canvasHeight
+ const x = (figure.position_x ?? 0.5) * canvasWidth;
+ const y = (figure.position_y ?? 0.5) * canvasHeight;
// Scale figure to reasonable size relative to canvas
- const baseScale = Math.min(canvasWidth / image.width, canvasHeight / image.height) * 0.3
- const figScale = baseScale * figure.scale
+ const baseScale = Math.min(canvasWidth / image.width, canvasHeight / image.height) * 0.3;
+ const figScale = baseScale * figure.scale;
/** What this figure would be standing on, given a centre point and a scale. */
- function violationAt(centerXPx: number, centerYPx: number, scale: number): PlacementViolation | null {
- if (!image) return null
+ function violationAt(
+ centerXPx: number,
+ centerYPx: number,
+ scale: number,
+ ): PlacementViolation | null {
+ if (!image) return null;
return checkPlacement({
rect: canvasRectToSurfaceCm({
centerXPx,
@@ -88,7 +92,7 @@ export function FigureLayer({
}),
deadZones,
seams,
- })
+ });
}
return (
@@ -106,58 +110,58 @@ export function FigureLayer({
onClick={onSelect}
onTap={onSelect}
onDragStart={() => {
- blockedBy.current = null
- lastLegalPos.current = { x, y }
- escapingZone.current = violationAt(x, y, figScale) !== null
+ blockedBy.current = null;
+ lastLegalPos.current = { x, y };
+ escapingZone.current = violationAt(x, y, figScale) !== null;
}}
onDragMove={(e) => {
// Remember the furthest legal point of the gesture. The drag itself is
// never constrained: a figure has to be able to cross a seam to reach
// the other panel, and every route between panels passes through the
// buffer band.
- const node = e.target
+ const node = e.target;
if (violationAt(node.x(), node.y(), figScale) === null) {
- lastLegalPos.current = { x: node.x(), y: node.y() }
+ lastLegalPos.current = { x: node.x(), y: node.y() };
}
}}
onDragEnd={(e) => {
- const node = e.target
- const violation = violationAt(node.x(), node.y(), figScale)
+ const node = e.target;
+ const violation = violationAt(node.x(), node.y(), figScale);
// Design principle 6: a figure cannot come to rest on a fixture or a
// glass seam. It lands as close to the target as the surface allows
// instead. A figure that was already stranded in a zone is left alone,
// so a rescue drag is never undone.
if (violation && !escapingZone.current) {
- const fallback = lastLegalPos.current ?? { x, y }
- node.position(fallback)
- node.getLayer()?.batchDraw()
- onBlocked(violation)
- onDragEnd(figure.id, fallback.x / canvasWidth, fallback.y / canvasHeight)
- return
+ const fallback = lastLegalPos.current ?? { x, y };
+ node.position(fallback);
+ node.getLayer()?.batchDraw();
+ onBlocked(violation);
+ onDragEnd(figure.id, fallback.x / canvasWidth, fallback.y / canvasHeight);
+ return;
}
- if (violation) onBlocked(violation)
- onDragEnd(figure.id, node.x() / canvasWidth, node.y() / canvasHeight)
+ if (violation) onBlocked(violation);
+ onDragEnd(figure.id, node.x() / canvasWidth, node.y() / canvasHeight);
}}
onTransformEnd={() => {
- const node = imageRef.current
- if (!node) return
- const newScaleX = node.scaleX()
+ const node = imageRef.current;
+ if (!node) return;
+ const newScaleX = node.scaleX();
// Growing a figure can push it into a zone that it fitted beside.
- const violation = violationAt(node.x(), node.y(), newScaleX)
+ const violation = violationAt(node.x(), node.y(), newScaleX);
if (violation && violationAt(node.x(), node.y(), figScale) === null) {
- node.scaleX(figScale)
- node.scaleY(figScale)
- node.getLayer()?.batchDraw()
- onBlocked(violation)
- return
+ node.scaleX(figScale);
+ node.scaleY(figScale);
+ node.getLayer()?.batchDraw();
+ onBlocked(violation);
+ return;
}
- node.scaleX(newScaleX)
- node.scaleY(newScaleX)
- onScaleChange(figure.id, newScaleX / baseScale)
+ node.scaleX(newScaleX);
+ node.scaleY(newScaleX);
+ onScaleChange(figure.id, newScaleX / baseScale);
}}
/>
{isSelected && (
@@ -166,11 +170,11 @@ export function FigureLayer({
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
keepRatio={true}
boundBoxFunc={(oldBox, newBox) => {
- if (newBox.width < 20 || newBox.height < 20) return oldBox
- return newBox
+ if (newBox.width < 20 || newBox.height < 20) return oldBox;
+ return newBox;
}}
/>
)}
>
- )
+ );
}
diff --git a/app/src/components/figures/FigureCard.tsx b/app/src/components/figures/FigureCard.tsx
index 5312a45..4433cab 100644
--- a/app/src/components/figures/FigureCard.tsx
+++ b/app/src/components/figures/FigureCard.tsx
@@ -1,55 +1,55 @@
-'use client'
+'use client';
-import { useState } from 'react'
-import { Input } from '@/components/ui/input'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Trash2, Upload, Check } from 'lucide-react'
-import { getImageUrl } from '@/lib/supabase/storage'
-import { useUpdateFigure, useDeleteFigure } from '@/hooks/useFigures'
-import { useSupabaseUpload } from '@/hooks/useSupabaseUpload'
-import { toast } from 'sonner'
-import type { Figure } from '@/types/database'
+import { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Trash2, Upload, Check } from 'lucide-react';
+import { getImageUrl } from '@/lib/supabase/storage';
+import { useUpdateFigure, useDeleteFigure } from '@/hooks/useFigures';
+import { useSupabaseUpload } from '@/hooks/useSupabaseUpload';
+import { toast } from 'sonner';
+import type { Figure } from '@/types/database';
interface FigureCardProps {
- figure: Figure
- projectId: string
+ figure: Figure;
+ projectId: string;
}
export function FigureCard({ figure, projectId }: FigureCardProps) {
- const [label, setLabel] = useState(figure.label ?? '')
- const updateFigure = useUpdateFigure(projectId)
- const deleteFigure = useDeleteFigure(projectId)
- const { upload, uploading } = useSupabaseUpload(projectId)
+ const [label, setLabel] = useState(figure.label ?? '');
+ const updateFigure = useUpdateFigure(projectId);
+ const deleteFigure = useDeleteFigure(projectId);
+ const { upload, uploading } = useSupabaseUpload(projectId);
- const originalUrl = getImageUrl(figure.original_photo_url)
- const styledUrl = figure.styled_url ? getImageUrl(figure.styled_url) : null
+ const originalUrl = getImageUrl(figure.original_photo_url);
+ const styledUrl = figure.styled_url ? getImageUrl(figure.styled_url) : null;
function handleLabelBlur() {
if (label !== (figure.label ?? '')) {
- updateFigure.mutate({ id: figure.id, data: { label: label || null } })
+ updateFigure.mutate({ id: figure.id, data: { label: label || null } });
}
}
async function handleStyledUpload(e: React.ChangeEvent) {
- const file = e.target.files?.[0]
- if (!file) return
+ const file = e.target.files?.[0];
+ if (!file) return;
- const result = await upload(file, 'styled')
+ const result = await upload(file, 'styled');
if (!result) {
- toast.error('Upload failed')
- return
+ toast.error('Upload failed');
+ return;
}
updateFigure.mutate(
{ id: figure.id, data: { styled_url: result.path, status: 'styled' } },
- { onSuccess: () => toast.success('Styled version uploaded') }
- )
+ { onSuccess: () => toast.success('Styled version uploaded') },
+ );
}
function handleDelete() {
- if (!confirm('Delete this figure?')) return
- deleteFigure.mutate(figure.id)
+ if (!confirm('Delete this figure?')) return;
+ deleteFigure.mutate(figure.id);
}
return (
@@ -60,7 +60,11 @@ export function FigureCard({ figure, projectId }: FigureCardProps) {
@@ -88,7 +101,7 @@ export function FigureCard({ figure, projectId }: FigureCardProps) {
setLabel(e.target.value)}
+ onChange={(e) => setLabel(e.target.value)}
onBlur={handleLabelBlur}
placeholder="Label (e.g., Roli + girlfriend)"
className="h-10 text-base bg-transparent border-white/[0.06]"
@@ -110,5 +123,5 @@ export function FigureCard({ figure, projectId }: FigureCardProps) {
- )
+ );
}
diff --git a/app/src/components/figures/FigureUploader.tsx b/app/src/components/figures/FigureUploader.tsx
index 6673433..508e0b3 100644
--- a/app/src/components/figures/FigureUploader.tsx
+++ b/app/src/components/figures/FigureUploader.tsx
@@ -1,46 +1,49 @@
-'use client'
+'use client';
-import { useCallback } from 'react'
-import { useDropzone } from 'react-dropzone'
-import { Upload, CloudUpload } from 'lucide-react'
-import { useSupabaseUpload } from '@/hooks/useSupabaseUpload'
-import { useCreateFigure } from '@/hooks/useFigures'
-import { toast } from 'sonner'
+import { useCallback } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { Upload, CloudUpload } from 'lucide-react';
+import { useSupabaseUpload } from '@/hooks/useSupabaseUpload';
+import { useCreateFigure } from '@/hooks/useFigures';
+import { toast } from 'sonner';
interface FigureUploaderProps {
- projectId: string
+ projectId: string;
}
export function FigureUploader({ projectId }: FigureUploaderProps) {
- const { upload, uploading } = useSupabaseUpload(projectId)
- const createFigure = useCreateFigure()
+ const { upload, uploading } = useSupabaseUpload(projectId);
+ const createFigure = useCreateFigure();
- const onDrop = useCallback(async (files: File[]) => {
- for (const file of files) {
- const result = await upload(file, 'originals')
- if (!result) {
- toast.error(`Failed to upload ${file.name}`)
- continue
- }
-
- createFigure.mutate(
- {
- project_id: projectId,
- original_photo_url: result.path,
- label: file.name.replace(/\.[^/.]+$/, ''),
- },
- {
- onError: (err) => toast.error(err.message),
+ const onDrop = useCallback(
+ async (files: File[]) => {
+ for (const file of files) {
+ const result = await upload(file, 'originals');
+ if (!result) {
+ toast.error(`Failed to upload ${file.name}`);
+ continue;
}
- )
- }
- }, [upload, createFigure, projectId])
+
+ createFigure.mutate(
+ {
+ project_id: projectId,
+ original_photo_url: result.path,
+ label: file.name.replace(/\.[^/.]+$/, ''),
+ },
+ {
+ onError: (err) => toast.error(err.message),
+ },
+ );
+ }
+ },
+ [upload, createFigure, projectId],
+ );
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] },
disabled: uploading,
- })
+ });
return (
- {uploading ? 'Uploading...' : isDragActive ? 'Drop photos here' : 'Drop photos or click to upload'}
+ {uploading
+ ? 'Uploading...'
+ : isDragActive
+ ? 'Drop photos here'
+ : 'Drop photos or click to upload'}
PNG, JPG, WEBP — one photo per person or group
- )
+ );
}
diff --git a/app/src/components/layout/AppShell.tsx b/app/src/components/layout/AppShell.tsx
index a63f033..786e43b 100644
--- a/app/src/components/layout/AppShell.tsx
+++ b/app/src/components/layout/AppShell.tsx
@@ -1,60 +1,62 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import Link from 'next/link'
-import { useRouter, usePathname } from 'next/navigation'
-import { createClient } from '@/lib/supabase/client'
-import { useAuth } from '@/components/providers/AuthProvider'
-import { Button } from '@/components/ui/button'
-import { cn } from '@/lib/utils'
-import { LogOut, Menu, X, User } from 'lucide-react'
-import type { ReactNode } from 'react'
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { useRouter, usePathname } from 'next/navigation';
+import { createClient } from '@/lib/supabase/client';
+import { useAuth } from '@/components/providers/AuthProvider';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { LogOut, Menu, X, User } from 'lucide-react';
+import type { ReactNode } from 'react';
const NAV_LINKS = [
{ href: '/projects', label: 'Projects' },
{ href: '/#how-it-works', label: 'How It Works' },
-]
+];
export function AppShell({ children }: { children: ReactNode }) {
- const { user } = useAuth()
- const router = useRouter()
- const pathname = usePathname()
- const supabase = createClient()
- const [scrolled, setScrolled] = useState(false)
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
+ const { user } = useAuth();
+ const router = useRouter();
+ const pathname = usePathname();
+ const supabase = createClient();
+ const [scrolled, setScrolled] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
function onScroll() {
- setScrolled(window.scrollY > 10)
+ setScrolled(window.scrollY > 10);
}
- window.addEventListener('scroll', onScroll, { passive: true })
- onScroll()
- return () => window.removeEventListener('scroll', onScroll)
- }, [])
+ window.addEventListener('scroll', onScroll, { passive: true });
+ onScroll();
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
// Close the mobile menu on route change. Adjusting state during render with a
// remembered previous value (React "you might not need an effect") avoids the
// cascading re-render an effect-driven setState would trigger.
- const [prevPathname, setPrevPathname] = useState(pathname)
+ const [prevPathname, setPrevPathname] = useState(pathname);
if (pathname !== prevPathname) {
- setPrevPathname(pathname)
- setMobileMenuOpen(false)
+ setPrevPathname(pathname);
+ setMobileMenuOpen(false);
}
// Prevent body scroll when mobile menu is open
useEffect(() => {
if (mobileMenuOpen) {
- document.body.style.overflow = 'hidden'
+ document.body.style.overflow = 'hidden';
} else {
- document.body.style.overflow = ''
+ document.body.style.overflow = '';
}
- return () => { document.body.style.overflow = '' }
- }, [mobileMenuOpen])
+ return () => {
+ document.body.style.overflow = '';
+ };
+ }, [mobileMenuOpen]);
async function handleLogout() {
- await supabase.auth.signOut()
- router.push('/login')
- router.refresh()
+ await supabase.auth.signOut();
+ router.push('/login');
+ router.refresh();
}
return (
@@ -64,7 +66,7 @@ export function AppShell({ children }: { children: ReactNode }) {
'sticky top-0 z-50 transition-all duration-300',
scrolled
? 'glass-strong border-b border-white/[0.06] shadow-lg shadow-black/10'
- : 'bg-transparent'
+ : 'bg-transparent',
)}
>
@@ -78,7 +80,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{/* Desktop nav */}
- {NAV_LINKS.map(link => (
+ {NAV_LINKS.map((link) => (
{link.label}
@@ -136,21 +138,24 @@ export function AppShell({ children }: { children: ReactNode }) {
{/* Glow line */}
-
+
{/* Full-screen mobile menu overlay */}
- {NAV_LINKS.map(link => (
+ {NAV_LINKS.map((link) => (
setMobileMenuOpen(false)}
>
@@ -177,7 +182,10 @@ export function AppShell({ children }: { children: ReactNode }) {
{user.email?.split('@')[0]}
{ setMobileMenuOpen(false); handleLogout() }}
+ onClick={() => {
+ setMobileMenuOpen(false);
+ handleLogout();
+ }}
className="w-full max-w-sm text-center px-6 py-5 text-xl font-medium text-muted-foreground hover:text-foreground rounded-2xl hover:bg-white/[0.04] transition-colors"
>
Sign out
@@ -195,9 +203,7 @@ export function AppShell({ children }: { children: ReactNode }) {
-
- {children}
-
+ {children}
- )
+ );
}
diff --git a/app/src/components/layout/ProjectStepNav.tsx b/app/src/components/layout/ProjectStepNav.tsx
index be632df..dc7db19 100644
--- a/app/src/components/layout/ProjectStepNav.tsx
+++ b/app/src/components/layout/ProjectStepNav.tsx
@@ -1,15 +1,15 @@
-'use client'
+'use client';
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-import { cn } from '@/lib/utils'
-import { Users, Palette, Ruler, Layers, Download, Check, ArrowLeft } from 'lucide-react'
-import { useProject } from '@/hooks/useProject'
-import { useFigures } from '@/hooks/useFigures'
-import { useSurface } from '@/hooks/useSurface'
-import { useComposition } from '@/hooks/useComposition'
-import { PROJECT_STEPS, type ProjectStepId } from '@/lib/config/project-steps'
-import { deriveProjectProgress } from '@/lib/domain/project-progress'
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { cn } from '@/lib/utils';
+import { Users, Palette, Ruler, Layers, Download, Check, ArrowLeft } from 'lucide-react';
+import { useProject } from '@/hooks/useProject';
+import { useFigures } from '@/hooks/useFigures';
+import { useSurface } from '@/hooks/useSurface';
+import { useComposition } from '@/hooks/useComposition';
+import { PROJECT_STEPS, type ProjectStepId } from '@/lib/config/project-steps';
+import { deriveProjectProgress } from '@/lib/domain/project-progress';
/** Icons are the only per-step thing the nav owns; the steps themselves are config. */
const STEP_ICONS: Record = {
@@ -18,14 +18,14 @@ const STEP_ICONS: Record = {
surface: Ruler,
compose: Layers,
export: Download,
-}
+};
export function ProjectStepNav({ projectId }: { projectId: string }) {
- const pathname = usePathname()
- const { data: project } = useProject(projectId)
- const { data: figures } = useFigures(projectId)
- const { data: surface } = useSurface(projectId)
- const { data: composition } = useComposition(projectId)
+ const pathname = usePathname();
+ const { data: project } = useProject(projectId);
+ const { data: figures } = useFigures(projectId);
+ const { data: surface } = useSurface(projectId);
+ const { data: composition } = useComposition(projectId);
// Same derivation the dashboard badge uses, so the two cannot disagree.
const { completed } = deriveProjectProgress({
@@ -33,10 +33,10 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
hasStyle: !!project?.style_id,
hasSurface: !!surface,
hasComposition: !!composition,
- })
- const completedSteps = new Set(completed)
+ });
+ const completedSteps = new Set(completed);
- const activeIndex = PROJECT_STEPS.findIndex(s => pathname.endsWith(`/${s.href}`))
+ const activeIndex = PROJECT_STEPS.findIndex((s) => pathname.endsWith(`/${s.href}`));
return (
@@ -55,11 +55,11 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
{/* Steps — horizontally scrollable on mobile */}
{PROJECT_STEPS.map((step, i) => {
- const href = `/project/${projectId}/${step.href}`
- const isActive = pathname.endsWith(`/${step.href}`)
- const isComplete = completedSteps.has(step.id)
- const isPast = i < activeIndex
- const Icon = STEP_ICONS[step.id]
+ const href = `/project/${projectId}/${step.href}`;
+ const isActive = pathname.endsWith(`/${step.href}`);
+ const isComplete = completedSteps.has(step.id);
+ const isPast = i < activeIndex;
+ const Icon = STEP_ICONS[step.id];
return (
@@ -68,7 +68,7 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
)}
@@ -81,7 +81,7 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
: isComplete
? 'text-foreground/80 hover:text-foreground hover:bg-white/[0.04]'
- : 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]'
+ : 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]',
)}
>
{isComplete && !isActive ? (
@@ -94,10 +94,10 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
{step.label}
- )
+ );
})}
- )
+ );
}
diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx
index 6a52ac0..e02495e 100644
--- a/app/src/components/projects/ProjectCard.tsx
+++ b/app/src/components/projects/ProjectCard.tsx
@@ -1,16 +1,16 @@
-'use client'
+'use client';
-import Link from 'next/link'
-import { Card, CardContent } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Trash2, Palette, ArrowUpRight } from 'lucide-react'
-import { deriveProjectProgress } from '@/lib/domain/project-progress'
-import type { Project } from '@/types/database'
+import Link from 'next/link';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Trash2, Palette, ArrowUpRight } from 'lucide-react';
+import { deriveProjectProgress } from '@/lib/domain/project-progress';
+import type { Project } from '@/types/database';
interface ProjectCardProps {
- project: Project
- onDelete: (id: string) => void
+ project: Project;
+ onDelete: (id: string) => void;
}
export function ProjectCard({ project, onDelete }: ProjectCardProps) {
@@ -21,7 +21,7 @@ export function ProjectCard({ project, onDelete }: ProjectCardProps) {
hasStyle: !!project.style_id,
hasSurface: (project.surfaces?.length ?? 0) > 0,
hasComposition: (project.compositions?.length ?? 0) > 0,
- })
+ });
return (
@@ -63,10 +63,13 @@ export function ProjectCard({ project, onDelete }: ProjectCardProps) {
variant="ghost"
size="icon"
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-all duration-200 h-8 w-8 rounded-full hover:bg-destructive/10"
- onClick={(e) => { e.preventDefault(); onDelete(project.id) }}
+ onClick={(e) => {
+ e.preventDefault();
+ onDelete(project.id);
+ }}
>
- )
+ );
}
diff --git a/app/src/components/providers/AuthProvider.tsx b/app/src/components/providers/AuthProvider.tsx
index c3a823e..cee0f3c 100644
--- a/app/src/components/providers/AuthProvider.tsx
+++ b/app/src/components/providers/AuthProvider.tsx
@@ -1,42 +1,40 @@
-'use client'
+'use client';
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
-import { createClient } from '@/lib/supabase/client'
-import type { User } from '@supabase/supabase-js'
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+import { createClient } from '@/lib/supabase/client';
+import type { User } from '@supabase/supabase-js';
interface AuthContext {
- user: User | null
- loading: boolean
+ user: User | null;
+ loading: boolean;
}
-const AuthContext = createContext({ user: null, loading: true })
+const AuthContext = createContext({ user: null, loading: true });
export function useAuth() {
- return useContext(AuthContext)
+ return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: ReactNode }) {
- const [user, setUser] = useState(null)
- const [loading, setLoading] = useState(true)
- const [supabase] = useState(() => createClient())
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [supabase] = useState(() => createClient());
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
- setUser(user)
- setLoading(false)
- })
-
- const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
- setUser(session?.user ?? null)
- setLoading(false)
- })
-
- return () => subscription.unsubscribe()
- }, [supabase])
-
- return (
-
- {children}
-
- )
+ setUser(user);
+ setLoading(false);
+ });
+
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, session) => {
+ setUser(session?.user ?? null);
+ setLoading(false);
+ });
+
+ return () => subscription.unsubscribe();
+ }, [supabase]);
+
+ return {children} ;
}
diff --git a/app/src/components/providers/QueryProvider.tsx b/app/src/components/providers/QueryProvider.tsx
index def5b36..239290d 100644
--- a/app/src/components/providers/QueryProvider.tsx
+++ b/app/src/components/providers/QueryProvider.tsx
@@ -1,23 +1,22 @@
-'use client'
+'use client';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { useState, type ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useState, type ReactNode } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
- const [queryClient] = useState(() => new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 30 * 1000,
- gcTime: 5 * 60 * 1000,
- refetchOnWindowFocus: false,
- retry: 2,
- },
- },
- }))
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30 * 1000,
+ gcTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ retry: 2,
+ },
+ },
+ }),
+ );
- return (
-
- {children}
-
- )
+ return {children} ;
}
diff --git a/app/src/components/styles/StyleGallery.tsx b/app/src/components/styles/StyleGallery.tsx
index d347d37..2eeaf69 100644
--- a/app/src/components/styles/StyleGallery.tsx
+++ b/app/src/components/styles/StyleGallery.tsx
@@ -1,20 +1,20 @@
-'use client'
+'use client';
-import { cn } from '@/lib/utils'
-import { Check } from 'lucide-react'
-import type { Style } from '@/types/database'
+import { cn } from '@/lib/utils';
+import { Check } from 'lucide-react';
+import type { Style } from '@/types/database';
interface StyleGalleryProps {
- styles: Style[]
- selectedId: string | null
- onSelect: (id: string) => void
+ styles: Style[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
}
export function StyleGallery({ styles, selectedId, onSelect }: StyleGalleryProps) {
return (
- {styles.map(style => {
- const isSelected = style.id === selectedId
+ {styles.map((style) => {
+ const isSelected = style.id === selectedId;
return (
onSelect(style.id)}
>
@@ -34,11 +34,15 @@ export function StyleGallery({ styles, selectedId, onSelect }: StyleGalleryProps
)}
- {style.emotional_tone}
- {style.description}
+
+ {style.emotional_tone}
+
+
+ {style.description}
+
- )
+ );
})}
- )
+ );
}
diff --git a/app/src/components/ui/ScrollReveal.tsx b/app/src/components/ui/ScrollReveal.tsx
index 2bd405a..ce7132e 100644
--- a/app/src/components/ui/ScrollReveal.tsx
+++ b/app/src/components/ui/ScrollReveal.tsx
@@ -1,52 +1,57 @@
-'use client'
+'use client';
-import { useEffect, useRef } from 'react'
-import type { ReactNode } from 'react'
+import { useEffect, useRef } from 'react';
+import type { ReactNode } from 'react';
interface ScrollRevealProps {
- children: ReactNode
- className?: string
- staggerChildren?: boolean
- staggerDelay?: number
+ children: ReactNode;
+ className?: string;
+ staggerChildren?: boolean;
+ staggerDelay?: number;
}
-export function ScrollReveal({ children, className = '', staggerChildren = false, staggerDelay = 100 }: ScrollRevealProps) {
- const ref = useRef(null)
+export function ScrollReveal({
+ children,
+ className = '',
+ staggerChildren = false,
+ staggerDelay = 100,
+}: ScrollRevealProps) {
+ const ref = useRef(null);
useEffect(() => {
- const el = ref.current
- if (!el) return
+ const el = ref.current;
+ if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
- entry.target.classList.add('revealed')
- observer.unobserve(entry.target)
+ entry.target.classList.add('revealed');
+ observer.unobserve(entry.target);
}
}
},
- { threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
- )
+ { threshold: 0.1, rootMargin: '0px 0px -40px 0px' },
+ );
if (staggerChildren) {
- const children = el.querySelectorAll(':scope > *')
+ const children = el.querySelectorAll(':scope > *');
children.forEach((child, i) => {
- ;(child as HTMLElement).style.transitionDelay = `${i * staggerDelay}ms`
- child.classList.add('reveal')
- observer.observe(child)
- })
+ (child as HTMLElement).style.transitionDelay = `${i * staggerDelay}ms`;
+ child.classList.add('reveal');
+ observer.observe(child);
+ });
} else {
- el.classList.add('reveal')
- observer.observe(el)
+ el.classList.add('reveal');
+ observer.observe(el);
}
- return () => observer.disconnect()
- }, [staggerChildren, staggerDelay])
+ return () => observer.disconnect();
+ }, [staggerChildren, staggerDelay]);
return (
{children}
- )
+ );
}
diff --git a/app/src/components/ui/badge.tsx b/app/src/components/ui/badge.tsx
index b20959d..e0bef52 100644
--- a/app/src/components/ui/badge.tsx
+++ b/app/src/components/ui/badge.tsx
@@ -1,52 +1,49 @@
-import { mergeProps } from "@base-ui/react/merge-props"
-import { useRender } from "@base-ui/react/use-render"
-import { cva, type VariantProps } from "class-variance-authority"
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+import { cva, type VariantProps } from 'class-variance-authority';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
const badgeVariants = cva(
- "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
- secondary:
- "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
- "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
- outline:
- "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
- ghost:
- "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
- link: "text-primary underline-offset-4 hover:underline",
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
+ outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
- variant: "default",
+ variant: 'default',
},
- }
-)
+ },
+);
function Badge({
className,
- variant = "default",
+ variant = 'default',
render,
...props
-}: useRender.ComponentProps<"span"> & VariantProps) {
+}: useRender.ComponentProps<'span'> & VariantProps) {
return useRender({
- defaultTagName: "span",
- props: mergeProps<"span">(
+ defaultTagName: 'span',
+ props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
- props
+ props,
),
render,
state: {
- slot: "badge",
+ slot: 'badge',
variant,
},
- })
+ });
}
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };
diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx
index 8dfb519..e8f8b51 100644
--- a/app/src/components/ui/button.tsx
+++ b/app/src/components/ui/button.tsx
@@ -1,51 +1,51 @@
-"use client"
+'use client';
-import { Button as ButtonPrimitive } from "@base-ui/react/button"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Button as ButtonPrimitive } from '@base-ui/react/button';
+import { cva, type VariantProps } from 'class-variance-authority';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
- icon: "size-8",
- "icon-xs":
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
+ icon: 'size-8',
+ 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ 'icon-sm':
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+ 'icon-lg': 'size-9',
},
},
defaultVariants: {
- variant: "default",
- size: "default",
+ variant: 'default',
+ size: 'default',
},
- }
-)
+ },
+);
function Button({
className,
- variant = "default",
- size = "default",
+ variant = 'default',
+ size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps) {
return (
@@ -54,7 +54,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
- )
+ );
}
-export { Button, buttonVariants }
+export { Button, buttonVariants };
diff --git a/app/src/components/ui/card.tsx b/app/src/components/ui/card.tsx
index 40cac5f..6f9118c 100644
--- a/app/src/components/ui/card.tsx
+++ b/app/src/components/ui/card.tsx
@@ -1,103 +1,92 @@
-import * as React from "react"
+import * as React from 'react';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
function Card({
className,
- size = "default",
+ size = 'default',
...props
-}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
- className
+ 'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+ className,
)}
{...props}
/>
- )
+ );
}
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardAction,
- CardDescription,
- CardContent,
-}
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
diff --git a/app/src/components/ui/dialog.tsx b/app/src/components/ui/dialog.tsx
index 014f5aa..0e42095 100644
--- a/app/src/components/ui/dialog.tsx
+++ b/app/src/components/ui/dialog.tsx
@@ -1,42 +1,39 @@
-"use client"
+'use client';
-import * as React from "react"
-import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+import * as React from 'react';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { XIcon } from "lucide-react"
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { XIcon } from 'lucide-react';
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
- return
+ return
;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
- return
+ return
;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
- return
+ return
;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
- return
+ return
;
}
-function DialogOverlay({
- className,
- ...props
-}: DialogPrimitive.Backdrop.Props) {
+function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
- )
+ );
}
function DialogContent({
@@ -45,7 +42,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
- showCloseButton?: boolean
+ showCloseButton?: boolean;
}) {
return (
@@ -53,8 +50,8 @@ function DialogContent({
@@ -62,32 +59,21 @@ function DialogContent({
{showCloseButton && (
- }
+ render={ }
>
-
+
Close
)}
- )
+ );
}
-function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
-
- )
+
+ );
}
function DialogFooter({
@@ -95,55 +81,47 @@ function DialogFooter({
showCloseButton = false,
children,
...props
-}: React.ComponentProps<"div"> & {
- showCloseButton?: boolean
+}: React.ComponentProps<'div'> & {
+ showCloseButton?: boolean;
}) {
return (
{children}
{showCloseButton && (
- }>
- Close
-
+ }>Close
)}
- )
+ );
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
- )
+ );
}
-function DialogDescription({
- className,
- ...props
-}: DialogPrimitive.Description.Props) {
+function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
- )
+ );
}
export {
@@ -157,4 +135,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
-}
+};
diff --git a/app/src/components/ui/dropdown-menu.tsx b/app/src/components/ui/dropdown-menu.tsx
index 9d5ebbd..1788ea1 100644
--- a/app/src/components/ui/dropdown-menu.tsx
+++ b/app/src/components/ui/dropdown-menu.tsx
@@ -1,35 +1,32 @@
-"use client"
+'use client';
-import * as React from "react"
-import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+import * as React from 'react';
+import { Menu as MenuPrimitive } from '@base-ui/react/menu';
-import { cn } from "@/lib/utils"
-import { ChevronRightIcon, CheckIcon } from "lucide-react"
+import { cn } from '@/lib/utils';
+import { ChevronRightIcon, CheckIcon } from 'lucide-react';
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
- return
+ return
;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
- return
+ return
;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
- return
+ return
;
}
function DropdownMenuContent({
- align = "start",
+ align = 'start',
alignOffset = 0,
- side = "bottom",
+ side = 'bottom',
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
- Pick<
- MenuPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset"
- >) {
+ Pick
) {
return (
- )
+ );
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
- return
+ return ;
}
function DropdownMenuLabel({
@@ -58,29 +58,29 @@ function DropdownMenuLabel({
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
- inset?: boolean
+ inset?: boolean;
}) {
return (
- )
+ );
}
function DropdownMenuItem({
className,
inset,
- variant = "default",
+ variant = 'default',
...props
}: MenuPrimitive.Item.Props & {
- inset?: boolean
- variant?: "default" | "destructive"
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
}) {
return (
- )
+ );
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
- return
+ return ;
}
function DropdownMenuSubTrigger({
@@ -106,7 +106,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
- inset?: boolean
+ inset?: boolean;
}) {
return (
{children}
- )
+ );
}
function DropdownMenuSubContent({
- align = "start",
+ align = 'start',
alignOffset = -3,
- side = "right",
+ side = 'right',
sideOffset = 0,
className,
...props
@@ -135,14 +135,17 @@ function DropdownMenuSubContent({
return (
- )
+ );
}
function DropdownMenuCheckboxItem({
@@ -152,7 +155,7 @@ function DropdownMenuCheckboxItem({
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
- inset?: boolean
+ inset?: boolean;
}) {
return (
-
+
{children}
- )
+ );
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
- return (
-
- )
+ return ;
}
function DropdownMenuRadioItem({
@@ -194,7 +191,7 @@ function DropdownMenuRadioItem({
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
- inset?: boolean
+ inset?: boolean;
}) {
return (
@@ -211,42 +208,35 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
-
+
{children}
- )
+ );
}
-function DropdownMenuSeparator({
- className,
- ...props
-}: MenuPrimitive.Separator.Props) {
+function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
- )
+ );
}
-function DropdownMenuShortcut({
- className,
- ...props
-}: React.ComponentProps<"span">) {
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
- )
+ );
}
export {
@@ -265,4 +255,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
-}
+};
diff --git a/app/src/components/ui/input.tsx b/app/src/components/ui/input.tsx
index 7d21bab..70c195a 100644
--- a/app/src/components/ui/input.tsx
+++ b/app/src/components/ui/input.tsx
@@ -1,20 +1,20 @@
-import * as React from "react"
-import { Input as InputPrimitive } from "@base-ui/react/input"
+import * as React from 'react';
+import { Input as InputPrimitive } from '@base-ui/react/input';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
- )
+ );
}
-export { Input }
+export { Input };
diff --git a/app/src/components/ui/label.tsx b/app/src/components/ui/label.tsx
index 74da65c..6d13a34 100644
--- a/app/src/components/ui/label.tsx
+++ b/app/src/components/ui/label.tsx
@@ -1,20 +1,20 @@
-"use client"
+'use client';
-import * as React from "react"
+import * as React from 'react';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Label({ className, ...props }: React.ComponentProps<"label">) {
+function Label({ className, ...props }: React.ComponentProps<'label'>) {
return (
- )
+ );
}
-export { Label }
+export { Label };
diff --git a/app/src/components/ui/select.tsx b/app/src/components/ui/select.tsx
index e8021f5..decf8d1 100644
--- a/app/src/components/ui/select.tsx
+++ b/app/src/components/ui/select.tsx
@@ -1,40 +1,40 @@
-"use client"
+'use client';
-import * as React from "react"
-import { Select as SelectPrimitive } from "@base-ui/react/select"
+import * as React from 'react';
+import { Select as SelectPrimitive } from '@base-ui/react/select';
-import { cn } from "@/lib/utils"
-import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+import { cn } from '@/lib/utils';
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
-const Select = SelectPrimitive.Root
+const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
- )
+ );
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
- )
+ );
}
function SelectTrigger({
className,
- size = "default",
+ size = 'default',
children,
...props
}: SelectPrimitive.Trigger.Props & {
- size?: "sm" | "default"
+ size?: 'sm' | 'default';
}) {
return (
{children}
- }
+ render={ }
/>
- )
+ );
}
function SelectContent({
className,
children,
- side = "bottom",
+ side = 'bottom',
sideOffset = 4,
- align = "center",
+ align = 'center',
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
>) {
return (
@@ -83,7 +81,10 @@ function SelectContent({
@@ -92,33 +93,26 @@ function SelectContent({
- )
+ );
}
-function SelectLabel({
- className,
- ...props
-}: SelectPrimitive.GroupLabel.Props) {
+function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
- )
+ );
}
-function SelectItem({
- className,
- children,
- ...props
-}: SelectPrimitive.Item.Props) {
+function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
@@ -133,20 +127,17 @@ function SelectItem({
- )
+ );
}
-function SelectSeparator({
- className,
- ...props
-}: SelectPrimitive.Separator.Props) {
+function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
- )
+ );
}
function SelectScrollUpButton({
@@ -158,14 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
- className
+ className,
)}
{...props}
>
-
+
- )
+ );
}
function SelectScrollDownButton({
@@ -177,14 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
- className
+ className,
)}
{...props}
>
-
+
- )
+ );
}
export {
@@ -198,4 +187,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
-}
+};
diff --git a/app/src/components/ui/separator.tsx b/app/src/components/ui/separator.tsx
index 6e1369e..34c7ba0 100644
--- a/app/src/components/ui/separator.tsx
+++ b/app/src/components/ui/separator.tsx
@@ -1,25 +1,21 @@
-"use client"
+'use client';
-import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+import { Separator as SeparatorPrimitive } from '@base-ui/react/separator';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Separator({
- className,
- orientation = "horizontal",
- ...props
-}: SeparatorPrimitive.Props) {
+function Separator({ className, orientation = 'horizontal', ...props }: SeparatorPrimitive.Props) {
return (
- )
+ );
}
-export { Separator }
+export { Separator };
diff --git a/app/src/components/ui/skeleton.tsx b/app/src/components/ui/skeleton.tsx
index 0118624..69c99b8 100644
--- a/app/src/components/ui/skeleton.tsx
+++ b/app/src/components/ui/skeleton.tsx
@@ -1,13 +1,13 @@
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
-export { Skeleton }
+export { Skeleton };
diff --git a/app/src/components/ui/tabs.tsx b/app/src/components/ui/tabs.tsx
index 56c4288..d0d56af 100644
--- a/app/src/components/ui/tabs.tsx
+++ b/app/src/components/ui/tabs.tsx
@@ -1,46 +1,39 @@
-"use client"
+'use client';
-import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from '@base-ui/react/tabs';
+import { cva, type VariantProps } from 'class-variance-authority';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Tabs({
- className,
- orientation = "horizontal",
- ...props
-}: TabsPrimitive.Root.Props) {
+function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
return (
- )
+ );
}
const tabsListVariants = cva(
- "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
- default: "bg-muted",
- line: "gap-1 bg-transparent",
+ default: 'bg-muted',
+ line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
- variant: "default",
+ variant: 'default',
},
- }
-)
+ },
+);
function TabsList({
className,
- variant = "default",
+ variant = 'default',
...props
}: TabsPrimitive.List.Props & VariantProps) {
return (
@@ -50,7 +43,7 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
- )
+ );
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
@@ -59,24 +52,24 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
- "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
- "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
- className
+ 'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
+ 'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
+ 'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
+ className,
)}
{...props}
/>
- )
+ );
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
- )
+ );
}
-export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/app/src/components/ui/textarea.tsx b/app/src/components/ui/textarea.tsx
index 04d27f7..46eab5e 100644
--- a/app/src/components/ui/textarea.tsx
+++ b/app/src/components/ui/textarea.tsx
@@ -1,18 +1,18 @@
-import * as React from "react"
+import * as React from 'react';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
- )
+ );
}
-export { Textarea }
+export { Textarea };
diff --git a/app/src/components/ui/tooltip.tsx b/app/src/components/ui/tooltip.tsx
index 69e8a82..c2e42f9 100644
--- a/app/src/components/ui/tooltip.tsx
+++ b/app/src/components/ui/tooltip.tsx
@@ -1,43 +1,31 @@
-"use client"
+'use client';
-import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-function TooltipProvider({
- delay = 0,
- ...props
-}: TooltipPrimitive.Provider.Props) {
- return (
-
- )
+function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
+ return ;
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
- return
+ return ;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
- return
+ return ;
}
function TooltipContent({
className,
- side = "top",
+ side = 'top',
sideOffset = 4,
- align = "center",
+ align = 'center',
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
- Pick<
- TooltipPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset"
- >) {
+ Pick) {
return (
@@ -60,7 +48,7 @@ function TooltipContent({
- )
+ );
}
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/app/src/hooks/useComposition.ts b/app/src/hooks/useComposition.ts
index d441c2c..8d39f57 100644
--- a/app/src/hooks/useComposition.ts
+++ b/app/src/hooks/useComposition.ts
@@ -1,26 +1,26 @@
-'use client'
+'use client';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import type { Composition } from '@/types/database'
-import type { UpsertComposition } from '@/lib/schemas/validation'
-import { fetchJson } from '@/lib/fetchJson'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { Composition } from '@/types/database';
+import type { UpsertComposition } from '@/lib/schemas/validation';
+import { fetchJson } from '@/lib/fetchJson';
export function useComposition(projectId: string) {
return useQuery({
queryKey: ['composition', projectId],
queryFn: async () => {
- const res = await fetch(`/api/compositions?project_id=${projectId}`)
- const json = await res.json()
- if (!json.success && json.error) throw new Error(json.error)
- return json.data ?? null
+ const res = await fetch(`/api/compositions?project_id=${projectId}`);
+ const json = await res.json();
+ if (!json.success && json.error) throw new Error(json.error);
+ return json.data ?? null;
},
enabled: !!projectId,
- })
+ });
}
/** Each save inserts the next version — the table keeps the history by design. */
export function useSaveComposition(projectId: string) {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpsertComposition) =>
fetchJson('/api/compositions', {
@@ -29,7 +29,7 @@ export function useSaveComposition(projectId: string) {
body: JSON.stringify(data),
}),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['composition', projectId] })
+ queryClient.invalidateQueries({ queryKey: ['composition', projectId] });
},
- })
+ });
}
diff --git a/app/src/hooks/useFigures.ts b/app/src/hooks/useFigures.ts
index a36de46..4aa4c73 100644
--- a/app/src/hooks/useFigures.ts
+++ b/app/src/hooks/useFigures.ts
@@ -1,20 +1,20 @@
-'use client'
+'use client';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import type { Figure } from '@/types/database'
-import type { CreateFigure, UpdateFigure } from '@/lib/schemas/validation'
-import { fetchJson } from '@/lib/fetchJson'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { Figure } from '@/types/database';
+import type { CreateFigure, UpdateFigure } from '@/lib/schemas/validation';
+import { fetchJson } from '@/lib/fetchJson';
export function useFigures(projectId: string) {
return useQuery({
queryKey: ['figures', projectId],
queryFn: () => fetchJson(`/api/figures?project_id=${projectId}`),
enabled: !!projectId,
- })
+ });
}
export function useCreateFigure() {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateFigure) =>
fetchJson('/api/figures', {
@@ -23,13 +23,13 @@ export function useCreateFigure() {
body: JSON.stringify(data),
}),
onSuccess: (_, variables) => {
- queryClient.invalidateQueries({ queryKey: ['figures', variables.project_id] })
+ queryClient.invalidateQueries({ queryKey: ['figures', variables.project_id] });
},
- })
+ });
}
export function useUpdateFigure(projectId: string) {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateFigure }) =>
fetchJson(`/api/figures/${id}`, {
@@ -38,18 +38,17 @@ export function useUpdateFigure(projectId: string) {
body: JSON.stringify(data),
}),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['figures', projectId] })
+ queryClient.invalidateQueries({ queryKey: ['figures', projectId] });
},
- })
+ });
}
export function useDeleteFigure(projectId: string) {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
- mutationFn: (id: string) =>
- fetchJson(`/api/figures/${id}`, { method: 'DELETE' }),
+ mutationFn: (id: string) => fetchJson(`/api/figures/${id}`, { method: 'DELETE' }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['figures', projectId] })
+ queryClient.invalidateQueries({ queryKey: ['figures', projectId] });
},
- })
+ });
}
diff --git a/app/src/hooks/useProject.ts b/app/src/hooks/useProject.ts
index 66d739c..0e7f0ac 100644
--- a/app/src/hooks/useProject.ts
+++ b/app/src/hooks/useProject.ts
@@ -1,20 +1,20 @@
-'use client'
+'use client';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import type { Project } from '@/types/database'
-import type { UpdateProject } from '@/lib/schemas/validation'
-import { fetchJson } from '@/lib/fetchJson'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { Project } from '@/types/database';
+import type { UpdateProject } from '@/lib/schemas/validation';
+import { fetchJson } from '@/lib/fetchJson';
export function useProject(id: string) {
return useQuery({
queryKey: ['project', id],
queryFn: () => fetchJson(`/api/projects/${id}`),
enabled: !!id,
- })
+ });
}
export function useUpdateProject(id: string) {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProject) =>
fetchJson(`/api/projects/${id}`, {
@@ -23,8 +23,8 @@ export function useUpdateProject(id: string) {
body: JSON.stringify(data),
}),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['project', id] })
- queryClient.invalidateQueries({ queryKey: ['projects'] })
+ queryClient.invalidateQueries({ queryKey: ['project', id] });
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
},
- })
+ });
}
diff --git a/app/src/hooks/useProjects.ts b/app/src/hooks/useProjects.ts
index ff64a46..c52d909 100644
--- a/app/src/hooks/useProjects.ts
+++ b/app/src/hooks/useProjects.ts
@@ -1,19 +1,19 @@
-'use client'
+'use client';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import type { Project } from '@/types/database'
-import type { CreateProject } from '@/lib/schemas/validation'
-import { fetchJson } from '@/lib/fetchJson'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { Project } from '@/types/database';
+import type { CreateProject } from '@/lib/schemas/validation';
+import { fetchJson } from '@/lib/fetchJson';
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: () => fetchJson('/api/projects'),
- })
+ });
}
export function useCreateProject() {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProject) =>
fetchJson('/api/projects', {
@@ -22,14 +22,13 @@ export function useCreateProject() {
body: JSON.stringify(data),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
- })
+ });
}
export function useDeleteProject() {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
return useMutation({
- mutationFn: (id: string) =>
- fetchJson(`/api/projects/${id}`, { method: 'DELETE' }),
+ mutationFn: (id: string) => fetchJson(`/api/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
- })
+ });
}
diff --git a/app/src/hooks/useStyles.ts b/app/src/hooks/useStyles.ts
index 94479c6..422dc85 100644
--- a/app/src/hooks/useStyles.ts
+++ b/app/src/hooks/useStyles.ts
@@ -1,22 +1,22 @@
-'use client'
+'use client';
-import { useQuery } from '@tanstack/react-query'
-import { createClient } from '@/lib/supabase/client'
-import type { Style } from '@/types/database'
+import { useQuery } from '@tanstack/react-query';
+import { createClient } from '@/lib/supabase/client';
+import type { Style } from '@/types/database';
export function useStyles() {
return useQuery