diff --git a/api/prisma/migrations/20260409183000_global_assets/migration.sql b/api/prisma/migrations/20260409183000_global_assets/migration.sql new file mode 100644 index 0000000..a294a78 --- /dev/null +++ b/api/prisma/migrations/20260409183000_global_assets/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "global_assets" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "art_key" TEXT NOT NULL, + "s3_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "global_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "global_assets_user_id_art_key_key" ON "global_assets"("user_id", "art_key"); + +-- CreateIndex +CREATE INDEX "global_assets_user_id_idx" ON "global_assets"("user_id"); + +-- AddForeignKey +ALTER TABLE "global_assets" ADD CONSTRAINT "global_assets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a149230..c0ef589 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -15,7 +15,24 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - projects Project[] + projects Project[] + globalAssets GlobalAsset[] +} + +/// User-wide images; project assets with the same normalized art key shadow these. +model GlobalAsset { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + artKey String @map("art_key") + s3Key String @map("s3_key") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, artKey]) + @@index([userId]) + @@map("global_assets") } /// Optional CSV dataset: { "headers": string[], "rows": Record[] } diff --git a/api/src/lib/assetResolve.test.ts b/api/src/lib/assetResolve.test.ts new file mode 100644 index 0000000..f6f557d --- /dev/null +++ b/api/src/lib/assetResolve.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { mergeAssetS3KeysByNormalizedKey, normalizeArtLookupKey } from './assetResolve.js'; + +describe('normalizeArtLookupKey', () => { + it('lowercases and strips extension', () => { + expect(normalizeArtLookupKey('Hero.PNG')).toBe('hero'); + }); + + it('strips mustache tokens', () => { + expect(normalizeArtLookupKey('{{ Gold }}')).toBe('gold'); + }); +}); + +describe('mergeAssetS3KeysByNormalizedKey', () => { + it('project shadows global', () => { + const m = mergeAssetS3KeysByNormalizedKey( + [{ artKey: 'hero', s3Key: 'p/hero' }], + [{ artKey: 'Hero', s3Key: 'g/hero' }] + ); + expect(m.get('hero')).toBe('p/hero'); + }); +}); diff --git a/api/src/lib/assetResolve.ts b/api/src/lib/assetResolve.ts new file mode 100644 index 0000000..a481f5e --- /dev/null +++ b/api/src/lib/assetResolve.ts @@ -0,0 +1,25 @@ +/** Normalize for case-insensitive matching; strips common image extensions. */ +export function normalizeArtLookupKey(raw: string): string { + let s = raw.trim().toLowerCase(); + s = s.replace(/\{\{|\}\}/g, '').trim(); + s = s.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, ''); + return s; +} + +export type AssetKeyRow = { artKey: string; s3Key: string }; + +/** Build map normalizedKey -> s3Key (project rows win over global when both match). */ +export function mergeAssetS3KeysByNormalizedKey( + projectAssets: AssetKeyRow[], + globalAssets: AssetKeyRow[] +): Map { + const m = new Map(); + for (const a of globalAssets) { + const k = normalizeArtLookupKey(a.artKey); + if (!m.has(k)) m.set(k, a.s3Key); + } + for (const a of projectAssets) { + m.set(normalizeArtLookupKey(a.artKey), a.s3Key); + } + return m; +} diff --git a/api/src/lib/layoutArtKeys.test.ts b/api/src/lib/layoutArtKeys.test.ts index d7a7218..74061ed 100644 --- a/api/src/lib/layoutArtKeys.test.ts +++ b/api/src/lib/layoutArtKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { collectArtKeysFromLayoutState } from './layoutArtKeys.js'; +import { collectArtKeysFromLayoutState, collectPdfPrefetchArtKeys } from './layoutArtKeys.js'; describe('collectArtKeysFromLayoutState', () => { it('collects image art keys from v2 root', () => { @@ -59,4 +59,52 @@ describe('collectArtKeysFromLayoutState', () => { }); expect(keys).toEqual(['art']); }); + + it('collects fallback art key', () => { + const keys = collectArtKeysFromLayoutState({ + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 1, + height: 1, + artKey: 'hero', + fallbackArtKey: 'backup.png', + }, + ], + }); + expect(keys.sort()).toEqual(['backup.png', 'hero'].sort()); + }); +}); + +describe('collectPdfPrefetchArtKeys', () => { + it('adds dynamic column cell values', () => { + const layout = { + version: 2, + width: 10, + height: 10, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 1, + height: 1, + artKey: 'x', + dynamicSourceColumn: 'Photo', + }, + ], + }; + const rows = [ + { Photo: ' a.png ', Name: 'A' }, + { Photo: 'b.png', Name: 'B' }, + ]; + const keys = collectPdfPrefetchArtKeys(layout, rows); + expect(keys).toContain('x'); + expect(keys).toContain('a.png'); + expect(keys).toContain('b.png'); + }); }); diff --git a/api/src/lib/layoutArtKeys.ts b/api/src/lib/layoutArtKeys.ts index 92acbce..7fcedbd 100644 --- a/api/src/lib/layoutArtKeys.ts +++ b/api/src/lib/layoutArtKeys.ts @@ -7,8 +7,14 @@ export function collectArtKeysFromLayoutState(state: unknown): string[] { function visitNode(n: unknown): void { if (!n || typeof n !== 'object') return; const o = n as Record; - if (o.type === 'image' && typeof o.artKey === 'string' && o.artKey.trim()) { - keys.add(o.artKey.trim()); + if (o.type === 'image') { + if (typeof o.artKey === 'string' && o.artKey.trim()) { + keys.add(o.artKey.trim()); + } + const fb = (o as { fallbackArtKey?: unknown }).fallbackArtKey; + if (typeof fb === 'string' && fb.trim()) { + keys.add(fb.trim()); + } } if (o.type === 'group' && Array.isArray(o.children)) { for (const c of o.children) visitNode(c); @@ -24,3 +30,45 @@ export function collectArtKeysFromLayoutState(state: unknown): string[] { visitState(state as Record); return [...keys]; } + +/** + * All strings that may need signed asset URLs for PDF export: layout art keys, fallbacks, + * and every non-empty cell value for each image’s `dynamicSourceColumn` across `rows`. + */ +export function collectPdfPrefetchArtKeys( + layout: unknown, + rows: Record[] +): string[] { + const keys = new Set(); + for (const k of collectArtKeysFromLayoutState(layout)) { + keys.add(k); + } + + function visitNode(n: unknown): void { + if (!n || typeof n !== 'object') return; + const o = n as Record; + if (o.type === 'image') { + const col = o.dynamicSourceColumn; + if (typeof col === 'string' && col.trim()) { + const c = col.trim(); + for (const row of rows) { + const v = (row[c] ?? '').trim(); + if (v) keys.add(v); + } + } + } + if (o.type === 'group' && Array.isArray(o.children)) { + for (const c of o.children) visitNode(c); + } + } + + function visitState(s: Record): void { + if (Array.isArray(s.root)) for (const n of s.root) visitNode(n); + if (Array.isArray(s.elements)) for (const n of s.elements) visitNode(n); + } + + if (layout && typeof layout === 'object') { + visitState(layout as Record); + } + return [...keys]; +} diff --git a/api/src/lib/pdfExportPayload.test.ts b/api/src/lib/pdfExportPayload.test.ts index 68e1fb2..7e304df 100644 --- a/api/src/lib/pdfExportPayload.test.ts +++ b/api/src/lib/pdfExportPayload.test.ts @@ -91,11 +91,13 @@ describe('buildPdfExportPayload', () => { }, ]); prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'art1', s3Key: 'k1' }]); + prisma.globalAsset.findMany.mockResolvedValueOnce([]); const r = await buildPdfExportPayload('p', 'u', { dpi: 200 }); if ('error' in r) throw new Error(String(r.error)); expect(r.payload.type).toBe('export-pdf'); expect(r.payload.dpi).toBe(200); expect(r.payload.assetUrls).toEqual({ art1: 'https://signed.example/asset' }); + expect(r.payload.assetResolveOrder).toEqual(['art1']); }); }); diff --git a/api/src/lib/pdfExportPayload.ts b/api/src/lib/pdfExportPayload.ts index 939dd22..1c3f264 100644 --- a/api/src/lib/pdfExportPayload.ts +++ b/api/src/lib/pdfExportPayload.ts @@ -1,5 +1,6 @@ +import { mergeAssetS3KeysByNormalizedKey, normalizeArtLookupKey } from './assetResolve.js'; import { prisma } from './prisma.js'; -import { collectArtKeysFromLayoutState } from './layoutArtKeys.js'; +import { collectPdfPrefetchArtKeys } from './layoutArtKeys.js'; import { getAssetsBucket, getSignedGetUrl } from './s3.js'; /** UI / server default; slider range is 150–300. */ @@ -77,18 +78,42 @@ export async function buildPdfExportPayload( const artKeys = new Set(); for (const eg of exportGroups) { - for (const k of collectArtKeysFromLayoutState(eg.layout)) artKeys.add(k); + for (const k of collectPdfPrefetchArtKeys(eg.layout, eg.rows)) artKeys.add(k); } - const assets = await prisma.asset.findMany({ - where: { projectId, artKey: { in: [...artKeys] } }, - select: { artKey: true, s3Key: true }, - }); + const [projectAssets, globalAssets] = await Promise.all([ + prisma.asset.findMany({ + where: { projectId }, + select: { artKey: true, s3Key: true }, + }), + prisma.globalAsset.findMany({ + where: { userId }, + select: { artKey: true, s3Key: true }, + }), + ]); + + const merged = mergeAssetS3KeysByNormalizedKey(projectAssets, globalAssets); + const seenOrder = new Set(); + const assetResolveOrder: string[] = []; + for (const a of projectAssets) { + const n = normalizeArtLookupKey(a.artKey); + if (seenOrder.has(n)) continue; + seenOrder.add(n); + assetResolveOrder.push(a.artKey); + } + for (const a of globalAssets) { + const n = normalizeArtLookupKey(a.artKey); + if (seenOrder.has(n)) continue; + seenOrder.add(n); + assetResolveOrder.push(a.artKey); + } const assetsBucket = getAssetsBucket(); const assetUrls: Record = {}; - for (const a of assets) { - assetUrls[a.artKey] = await getSignedGetUrl(assetsBucket, a.s3Key, 3600); + for (const rk of artKeys) { + const sk = merged.get(normalizeArtLookupKey(rk)); + if (!sk) continue; + assetUrls[rk.trim()] = await getSignedGetUrl(assetsBucket, sk, 3600); } const timestamp = new Date().toISOString(); @@ -102,6 +127,7 @@ export async function buildPdfExportPayload( dpi: resolveExportPdfDpi(options?.dpi), groups: exportGroups, assetUrls, + assetResolveOrder, }; return { payload, timestamp }; diff --git a/api/src/lib/s3.test.ts b/api/src/lib/s3.test.ts index fe1e352..96170a8 100644 --- a/api/src/lib/s3.test.ts +++ b/api/src/lib/s3.test.ts @@ -18,6 +18,9 @@ vi.mock('@aws-sdk/client-s3', () => ({ PutObjectCommand: class { constructor(public input: unknown) {} }, + PutBucketCorsCommand: class { + constructor(public input: unknown) {} + }, GetObjectCommand: class { constructor(public input: unknown) {} }, @@ -92,7 +95,12 @@ describe('s3 helpers', () => { const prevAws = process.env.AWS_ENDPOINT_URL; process.env.NODE_ENV = 'development'; process.env.AWS_ENDPOINT_URL = 'http://localhost:4566'; - send.mockRejectedValueOnce(new Error('no bucket')).mockResolvedValueOnce({}); + send + .mockRejectedValueOnce(new Error('no bucket')) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('no bucket')) + .mockResolvedValueOnce({}) + .mockResolvedValue({}); const { ensureDevLocalStackBuckets } = await import('./s3.js'); await ensureDevLocalStackBuckets(); expect(send.mock.calls.length).toBeGreaterThan(0); @@ -107,7 +115,12 @@ describe('s3 helpers', () => { process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566'; const err = new Error('exists'); (err as Error & { name: string }).name = 'BucketAlreadyOwnedByYou'; - send.mockRejectedValueOnce(new Error('no head')).mockRejectedValueOnce(err); + send + .mockRejectedValueOnce(new Error('no head')) + .mockRejectedValueOnce(err) + .mockRejectedValueOnce(new Error('no head')) + .mockRejectedValueOnce(err) + .mockResolvedValue({}); const { ensureDevLocalStackBuckets } = await import('./s3.js'); await ensureDevLocalStackBuckets(); process.env.NODE_ENV = prevNode; diff --git a/api/src/lib/s3.ts b/api/src/lib/s3.ts index 5e74b45..e19c898 100644 --- a/api/src/lib/s3.ts +++ b/api/src/lib/s3.ts @@ -1,8 +1,10 @@ import { + CopyObjectCommand, CreateBucketCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command, + PutBucketCorsCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; @@ -41,6 +43,30 @@ export function getExportsBucket(): string { return b; } +/** Presigned GET from Vite (5173) needs CORS on bucket; LocalStack default bucket has none. */ +async function ensureLocalStackBucketCors(Bucket: string): Promise { + try { + await s3Client.send( + new PutBucketCorsCommand({ + Bucket, + CORSConfiguration: { + CORSRules: [ + { + AllowedHeaders: ['*'], + AllowedMethods: ['GET', 'HEAD', 'PUT', 'POST'], + AllowedOrigins: ['*'], + ExposeHeaders: ['ETag'], + MaxAgeSeconds: 3000, + }, + ], + }, + }) + ); + } catch (err) { + rootLogger.warn({ err, Bucket }, 'PutBucketCors failed (browser asset loads may break)'); + } +} + /** After Docker/LocalStack restarts, buckets may be missing; create them in dev only. */ export async function ensureDevLocalStackBuckets(): Promise { const endpoint = process.env.AWS_ENDPOINT_URL ?? ''; @@ -62,6 +88,9 @@ export async function ensureDevLocalStackBuckets(): Promise { } } } + for (const Bucket of buckets) { + await ensureLocalStackBucketCors(Bucket); + } } export async function putObject( @@ -80,6 +109,22 @@ export async function putObject( ); } +/** Server-side copy within the same bucket (e.g. promote project asset → global). */ +export async function copyObjectSameBucket( + bucket: string, + sourceKey: string, + destKey: string +): Promise { + const copySource = `${bucket}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`; + await s3Client.send( + new CopyObjectCommand({ + Bucket: bucket, + CopySource: copySource, + Key: destKey, + }) + ); +} + export async function listObjectKeys(bucket: string, prefix: string): Promise { const keys: string[] = []; let continuationToken: string | undefined; diff --git a/api/src/routes/assets.test.ts b/api/src/routes/assets.test.ts index 027f368..e393438 100644 --- a/api/src/routes/assets.test.ts +++ b/api/src/routes/assets.test.ts @@ -16,6 +16,7 @@ vi.mock('../lib/s3.js', () => ({ getAssetsBucket: () => 'assets-bucket', putObject: s3Mocks.putObject, getSignedGetUrl: s3Mocks.getSignedGetUrl, + copyObjectSameBucket: vi.fn(async () => {}), })); import { prisma } from '../test/prisma-mock.js'; @@ -33,6 +34,8 @@ describe('assets routes', () => { beforeEach(() => { s3Mocks.putObject.mockClear(); s3Mocks.putObject.mockResolvedValue(undefined); + prisma.globalAsset.findMany.mockReset(); + prisma.globalAsset.findMany.mockResolvedValue([]); }); it('404 when project missing', async () => { @@ -49,6 +52,7 @@ describe('assets routes', () => { const res = await request(app).get('/api/projects/p1/assets').set(authed()); expect(res.status).toBe(200); expect(res.body.assets[0].url).toBeUndefined(); + expect(Array.isArray(res.body.globalAssets)).toBe(true); }); it('includes signed URLs when requested', async () => { diff --git a/api/src/routes/assets.ts b/api/src/routes/assets.ts index 7e55eea..5626654 100644 --- a/api/src/routes/assets.ts +++ b/api/src/routes/assets.ts @@ -2,7 +2,12 @@ import { Router, type IRouter } from 'express'; import multer from 'multer'; import { prisma } from '../lib/prisma.js'; import { requireAuth } from '../middleware/auth.js'; -import { getAssetsBucket, getSignedGetUrl, putObject } from '../lib/s3.js'; +import { + copyObjectSameBucket, + getAssetsBucket, + getSignedGetUrl, + putObject, +} from '../lib/s3.js'; export const assetsRouter: IRouter = Router(); assetsRouter.use(requireAuth); @@ -12,6 +17,22 @@ const upload = multer({ limits: { fileSize: 25 * 1024 * 1024 }, }); +/** Canonical art key stored in DB (lowercase slug, no extension). */ +export function normalizeStoredArtKey(raw: string): string { + let s = raw.trim().toLowerCase(); + s = s.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, ''); + s = s.replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); + return s || 'asset'; +} + +function artKeyFromUpload(bodyArtKey: unknown, originalname: string): string { + if (typeof bodyArtKey === 'string' && bodyArtKey.trim()) { + return normalizeStoredArtKey(bodyArtKey); + } + const base = originalname.replace(/\.[^.]+$/, ''); + return normalizeStoredArtKey(base || originalname); +} + assetsRouter.get('/projects/:projectId/assets', async (req, res) => { const userId = req.user!.id; const projectId = String(req.params.projectId); @@ -22,25 +43,35 @@ assetsRouter.get('/projects/:projectId/assets', async (req, res) => { return; } - const assets = await prisma.asset.findMany({ - where: { projectId }, - orderBy: { createdAt: 'desc' }, - select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, - }); + const [assets, globalAssets] = await Promise.all([ + prisma.asset.findMany({ + where: { projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }), + prisma.globalAsset.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }), + ]); + const includeUrls = String(req.query.includeUrls) === '1' || String(req.query.includeUrls) === 'true'; if (!includeUrls) { - res.json({ assets }); + res.json({ assets, globalAssets }); return; } const bucket = getAssetsBucket(); - const withUrls = await Promise.all( - assets.map(async (a) => ({ - ...a, - url: await getSignedGetUrl(bucket, a.s3Key), - })) - ); - res.json({ assets: withUrls }); + const signRow = async (row: T) => ({ + ...row, + url: await getSignedGetUrl(bucket, row.s3Key), + }); + const [withProjectUrls, withGlobalUrls] = await Promise.all([ + Promise.all(assets.map(signRow)), + Promise.all(globalAssets.map(signRow)), + ]); + res.json({ assets: withProjectUrls, globalAssets: withGlobalUrls }); }); assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (req, res) => { @@ -60,10 +91,7 @@ assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (r } const bodyArtKey = (req.body as { artKey?: string }).artKey; - const artKey = - typeof bodyArtKey === 'string' && bodyArtKey.trim() - ? bodyArtKey.trim() - : file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'; + const artKey = artKeyFromUpload(bodyArtKey, file.originalname); const bucket = getAssetsBucket(); const s3Key = `${projectId}/${artKey}`; @@ -91,3 +119,111 @@ assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (r res.status(201).json({ asset }); }); + +/** Upload into the signed-in user's global library. */ +assetsRouter.post('/user/global-assets', upload.single('file'), async (req, res) => { + const userId = req.user!.id; + const file = req.file; + if (!file) { + res.status(400).json({ error: 'file field is required' }); + return; + } + + const bodyArtKey = (req.body as { artKey?: string }).artKey; + const artKey = artKeyFromUpload(bodyArtKey, file.originalname); + + const bucket = getAssetsBucket(); + const s3Key = `global/${userId}/${artKey}`; + + await putObject(bucket, s3Key, file.buffer, file.mimetype || 'application/octet-stream'); + + const row = await prisma.globalAsset.upsert({ + where: { userId_artKey: { userId, artKey } }, + create: { userId, artKey, s3Key }, + update: { s3Key }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }); + + res.status(201).json({ asset: row }); +}); + +/** Copy S3 object to global library and remove the project row (content moves to global). */ +assetsRouter.post('/projects/:projectId/assets/:assetId/promote-global', async (req, res) => { + const userId = req.user!.id; + const projectId = String(req.params.projectId); + const assetId = String(req.params.assetId); + + const project = await prisma.project.findFirst({ where: { id: projectId, userId } }); + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const existing = await prisma.asset.findFirst({ + where: { id: assetId, projectId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + const bucket = getAssetsBucket(); + const destKey = `global/${userId}/${normalizeStoredArtKey(existing.artKey)}`; + + await copyObjectSameBucket(bucket, existing.s3Key, destKey); + + const global = await prisma.$transaction(async (tx) => { + await tx.asset.delete({ where: { id: existing.id } }); + return tx.globalAsset.upsert({ + where: { userId_artKey: { userId, artKey: normalizeStoredArtKey(existing.artKey) } }, + create: { + userId, + artKey: normalizeStoredArtKey(existing.artKey), + s3Key: destKey, + }, + update: { s3Key: destKey }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }); + }); + + res.json({ asset: global }); +}); + +assetsRouter.delete('/projects/:projectId/assets/:assetId', async (req, res) => { + const userId = req.user!.id; + const projectId = String(req.params.projectId); + const assetId = String(req.params.assetId); + + const project = await prisma.project.findFirst({ where: { id: projectId, userId } }); + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const existing = await prisma.asset.findFirst({ + where: { id: assetId, projectId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + await prisma.asset.delete({ where: { id: existing.id } }); + res.status(204).end(); +}); + +assetsRouter.delete('/user/global-assets/:assetId', async (req, res) => { + const userId = req.user!.id; + const assetId = String(req.params.assetId); + + const existing = await prisma.globalAsset.findFirst({ + where: { id: assetId, userId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + await prisma.globalAsset.delete({ where: { id: existing.id } }); + res.status(204).end(); +}); diff --git a/api/src/routes/exports.test.ts b/api/src/routes/exports.test.ts index 7c4ec40..26206e6 100644 --- a/api/src/routes/exports.test.ts +++ b/api/src/routes/exports.test.ts @@ -50,6 +50,7 @@ function authed() { describe('exports routes', () => { beforeEach(() => { vi.clearAllMocks(); + prisma.globalAsset.findMany.mockResolvedValue([]); }); it('POST /export enqueues', async () => { diff --git a/api/src/test/prisma-mock.ts b/api/src/test/prisma-mock.ts index a75bc91..b76426c 100644 --- a/api/src/test/prisma-mock.ts +++ b/api/src/test/prisma-mock.ts @@ -1,7 +1,8 @@ import { vi } from 'vitest'; -function createMock() { - return { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createMock(): any { + const client: Record = { user: { findUnique: vi.fn(), create: vi.fn(), @@ -30,13 +31,27 @@ function createMock() { }, asset: { findMany: vi.fn(), + findFirst: vi.fn(), upsert: vi.fn(), + delete: vi.fn(), + }, + globalAsset: { + findMany: vi.fn(), + findFirst: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), }, - $transaction: vi.fn((ops: unknown) => { - if (Array.isArray(ops)) return Promise.all(ops as Promise[]); - return Promise.resolve(undefined); - }), }; + + client.$transaction = vi.fn(async (ops: unknown) => { + if (typeof ops === 'function') { + return (ops as (tx: typeof client) => Promise)(client); + } + if (Array.isArray(ops)) return Promise.all(ops as Promise[]); + return Promise.resolve(undefined); + }); + + return client; } export const prisma = createMock(); diff --git a/docker/localstack-ready.d/init-aws.sh b/docker/localstack-ready.d/init-aws.sh index 1dfd420..5962971 100755 --- a/docker/localstack-ready.d/init-aws.sh +++ b/docker/localstack-ready.d/init-aws.sh @@ -20,4 +20,24 @@ fi "${AWS[@]}" s3 mb "s3://cardgoose-exports" 2>/dev/null || true "${AWS[@]}" sqs create-queue --queue-name cardgoose-pdf-generation 2>/dev/null || true +# Browser (Vite) loads presigned URLs with crossOrigin=anonymous → needs ACAO on GET. +CORS_JSON="$(mktemp)" +trap 'rm -f "$CORS_JSON"' EXIT +cat >"$CORS_JSON" <<'EOF' +{ + "CORSRules": [ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "HEAD", "PUT", "POST"], + "AllowedOrigins": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3000 + } + ] +} +EOF +for b in cardgoose-assets cardgoose-exports; do + "${AWS[@]}" s3api put-bucket-cors --bucket "$b" --cors-configuration "file://$CORS_JSON" 2>/dev/null || true +done + echo "LocalStack bootstrap: S3 buckets + SQS queue ready" diff --git a/frontend/src/App.css b/frontend/src/App.css index cf45105..d8afe7f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -33,6 +33,31 @@ body.layout-editor-open .main.main-wide { box-sizing: border-box; } +body.assets-tab-open { + overflow: hidden; +} + +body.assets-tab-open .shell.shell--studio { + height: 100svh; + max-height: 100svh; + min-height: 0; + overflow: hidden; +} + +body.assets-tab-open .main.main-wide { + flex: 1; + min-height: 0; + overflow: hidden; + padding: 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + margin: 0; + max-width: none; + width: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + .top-nav { display: flex; align-items: center; @@ -84,6 +109,10 @@ body.layout-editor-open .main.main-wide { padding-top: 10px; } +.main-wide:has(.assets-shell) { + padding-top: 0; +} + .page-header { display: flex; flex-wrap: wrap; @@ -92,6 +121,10 @@ body.layout-editor-open .main.main-wide { margin-bottom: 16px; } +.dashboard-page-toolbar { + margin-bottom: 20px; +} + .inline-form { display: flex; gap: 8px; @@ -580,7 +613,11 @@ button[type='submit']:not(.auth-primary), .card-group-meta-chip ):not(.card-group-icon-btn):not(.card-group-add-slot):not(.card-group-title-hit):not( .card-group-url-drawer-save - ):not(.card-group-url-drawer-cancel):not(.card-group-url-drawer-shortcut):not( + ):not(.card-group-url-drawer-cancel):not(.assets-shell-ctrl):not(.layout-col-combo-trigger):not( + .layout-col-combo-option + ):not(.layout-col-combo-clear):not(.layout-fallback-asset-btn):not(.layout-fallback-clear-btn):not( + .layout-asset-picker-close + ):not( .layout-list-row-hit ) { padding: 10px 14px; @@ -646,6 +683,15 @@ button:disabled { flex-direction: column; } +.project-dashboard--assets-tab { + flex: 1; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + .layout-fullscreen { flex: 1; min-height: 0; @@ -1811,17 +1857,6 @@ button.zone-row-body:hover { text-align: left; } -.card-group-url-drawer-shortcut { - align-self: flex-start; - padding: 4px 10px; - border-radius: 6px; - border: 1px solid rgba(16, 185, 129, 0.35); - background: rgba(16, 185, 129, 0.08); - color: #a7f3d0; - font-size: 12px; - cursor: pointer; -} - .card-group-url-drawer-actions { display: flex; flex-wrap: wrap; @@ -1901,6 +1936,8 @@ button.zone-row-body:hover { /* —— Studio shell / nav —— */ .studio-shell-header { + --studio-header-h: 56px; + --studio-header-pad-x: 20px; flex-shrink: 0; background: #0b0b0b; border-bottom: 1px solid #161616; @@ -1912,11 +1949,18 @@ button.zone-row-body:hover { flex-wrap: wrap; align-items: center; gap: 12px 16px; - padding: 0 20px; - min-height: 52px; + padding: 0 var(--studio-header-pad-x, 20px); box-sizing: border-box; } +.studio-shell-row--primary { + min-height: var(--studio-header-h, 56px); + height: var(--studio-header-h, 56px); + flex-wrap: nowrap; + gap: 12px; + align-items: center; +} + .studio-shell-row--project { justify-content: space-between; } @@ -1924,9 +1968,6 @@ button.zone-row-body:hover { .studio-shell-row--editor-main { justify-content: space-between; align-items: center; - min-height: 40px; - padding-top: 4px; - padding-bottom: 3px; } .studio-shell-row--editor-menus { @@ -1935,6 +1976,8 @@ button.zone-row-body:hover { padding-bottom: 6px; align-items: center; border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-left: var(--studio-header-pad-x, 20px); + padding-right: var(--studio-header-pad-x, 20px); } .studio-shell-fill { @@ -1942,36 +1985,19 @@ button.zone-row-body:hover { min-width: 8px; } -.studio-shell-brand { +.studio-shell-logo-link { display: inline-flex; align-items: center; - gap: 10px; + justify-content: center; + flex-shrink: 0; + line-height: 0; text-decoration: none; - color: var(--text-h); - font-weight: 600; - font-size: 15px; - letter-spacing: -0.02em; -} - -.studio-shell-brand:hover { color: var(--accent); + border-radius: 6px; } -.studio-shell-brand--compact { - font-weight: 500; -} - -.studio-shell-brand--mark-only { - gap: 0; - line-height: 0; - padding: 4px 0; -} - -.studio-shell-header--dash .studio-shell-row { - min-height: 56px; - padding-left: 14px; - padding-right: 20px; - align-items: center; +.studio-shell-logo-link:hover { + color: #86efac; } .studio-shell-logo { @@ -1979,7 +2005,7 @@ button.zone-row-body:hover { align-items: center; justify-content: center; flex-shrink: 0; - color: var(--accent); + color: inherit; } .brand-logo-mark svg { @@ -1991,29 +2017,80 @@ button.zone-row-body:hover { fill: currentColor; } -.studio-shell-left { +.studio-breadcrumb { display: flex; align-items: center; - gap: 12px; + gap: 8px; min-width: 0; + flex: 1; } -.studio-shell-project-name { - font-size: 16px; - font-weight: 700; - color: #fafafa; - letter-spacing: -0.02em; +.studio-breadcrumb--project { + flex: 0 1 auto; + max-width: min(46vw, 440px); +} + +.studio-breadcrumb--editor { + flex: 1; + min-width: 0; +} + +.studio-bc-sep { + flex-shrink: 0; + color: rgba(161, 161, 170, 0.55); +} + +.studio-bc-text { + font-size: 14px; + font-weight: 500; + color: rgba(228, 228, 231, 0.92); + text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: min(40vw, 320px); + max-width: min(28vw, 220px); + letter-spacing: -0.01em; +} + +a.studio-bc-text:hover { + color: var(--accent); +} + +.studio-bc-text--current { + font-weight: 600; + color: #fafafa; + cursor: default; } -.studio-shell-project-name--muted { +.studio-bc-text--current:hover { + color: #fafafa; +} + +.studio-bc-text--muted { font-weight: 500; color: var(--text); } +.studio-bc-input { + min-width: 100px; + max-width: min(24vw, 200px); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font: inherit; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.studio-bc-input:hover, +.studio-bc-input:focus { + border-color: var(--border); + outline: none; +} + .studio-shell-right { flex-shrink: 0; } @@ -2161,55 +2238,7 @@ button.zone-row-body:hover { color: #6ee7b7; } -/* Editor chrome */ -.editor-breadcrumb { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; -} - -.editor-bc-sep { - flex-shrink: 0; - opacity: 0.35; - color: var(--text); -} - -.editor-bc-link { - font-size: 14px; - font-weight: 600; - color: var(--text-h); - text-decoration: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: min(28vw, 200px); -} - -.editor-bc-link:hover { - color: var(--accent); -} - -.editor-bc-input { - min-width: 120px; - max-width: 200px; - padding: 4px 8px; - border-radius: 6px; - border: 1px solid transparent; - background: rgba(255, 255, 255, 0.04); - color: var(--text-h); - font: inherit; - font-size: 14px; - font-weight: 500; -} - -.editor-bc-input:hover, -.editor-bc-input:focus { - border-color: var(--border); - outline: none; -} - +/* Editor chrome (breadcrumb uses .studio-breadcrumb / .studio-bc-*) */ .editor-bar-status-save { display: flex; align-items: center; @@ -2581,6 +2610,50 @@ label.layout-editor-footer-value-strip { justify-content: center; } +.layout-editor-footer-start-cluster { + display: flex; + align-items: stretch; + flex-shrink: 0; + min-width: 0; +} + +.layout-editor-footer-popover { + position: relative; + display: flex; + align-items: stretch; +} + +.layout-editor-footer-preview-source-btn { + max-width: 200px; + padding-right: 8px; +} + +.layout-editor-footer-preview-source-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-preview-source-popover { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + min-width: 220px; + max-width: min(320px, 86vw); + max-height: min(48vh, 280px); + overflow-y: auto; + list-style: none; + margin: 0; + padding: 4px 0; + background: rgba(28, 28, 32, 0.98); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); + z-index: 50; +} + .deck-preview-drawer { position: absolute; left: 0; @@ -2593,7 +2666,7 @@ label.layout-editor-footer-value-strip { transform: translateY(100%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: none; - overflow: visible; + overflow: hidden; } .deck-preview-drawer--open { @@ -2669,186 +2742,1211 @@ label.layout-editor-footer-value-strip { font-size: 12px; } -.deck-filmstrip { - flex: 0 0 auto; - height: 120px; - min-height: 120px; +.props-accordion-summary-with-icons { display: flex; - flex-direction: column; - border: 1px solid var(--border); - border-radius: 8px; - margin-top: 8px; - background: var(--panel); - overflow: hidden; - box-sizing: border-box; -} - -.deck-filmstrip--overlay { - margin-top: 0; - height: 132px; - min-height: 132px; - border-radius: 10px 10px 0 0; - border-bottom: none; - box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.55); - max-height: min(42vh, 200px); - background: rgba(22, 22, 22, 0.97); - backdrop-filter: blur(12px); - /* Let the data-source menu drop below the control without clipping */ - overflow: visible; + align-items: center; + gap: 8px; } -.deck-filmstrip-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - padding: 8px 12px 4px; - flex-shrink: 0; +.props-accordion-summary-with-icons::after { + float: none; + margin-left: auto; } -.deck-filmstrip-title { - font-size: 12px; - font-weight: 600; - color: var(--text-h); - letter-spacing: -0.01em; +.props-accordion-body--image-source { + gap: 14px; } -.deck-filmstrip-meta { +.layout-image-source-hint { + display: flex; + align-items: flex-start; + gap: 8px; font-size: 11px; + line-height: 1.4; color: var(--text); - opacity: 0.85; + opacity: 0.75; + margin: 0; } -.deck-filmstrip-source-picker { +.layout-col-combo { position: relative; + display: flex; + flex-direction: column; + gap: 6px; } -.deck-filmstrip-source-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 8px; +.layout-col-combo-label { font-size: 11px; - font-weight: 500; - color: var(--text-h); - background: rgba(255, 255, 255, 0.06); - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; - white-space: nowrap; - transition: - background 0.15s, - border-color 0.15s; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.95); } -.deck-filmstrip-source-btn:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent); +.layout-col-combo-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); + color: var(--text-h); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; } -.deck-filmstrip-source-label { - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; +.layout-col-combo-trigger-inner { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } -.deck-filmstrip-source-chevron { - opacity: 0.6; - transition: transform 0.18s ease; +.layout-col-combo-token-icon { + flex-shrink: 0; + color: #6ee7b7; + opacity: 0.9; } -.deck-filmstrip-source-chevron--open { - transform: rotate(180deg); +.layout-col-combo-token { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: #a7f3d0; + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.35); + border-radius: 6px; + padding: 2px 8px; } -.deck-filmstrip-source-menu { +.layout-col-combo-placeholder { + color: var(--text); + opacity: 0.45; + font-size: 12px; +} + +.layout-col-combo-chev { + flex-shrink: 0; + opacity: 0.45; +} + +.layout-col-combo-panel { position: absolute; - top: calc(100% + 4px); - bottom: auto; left: 0; - min-width: 180px; - max-width: 260px; - max-height: min(40vh, 240px); - overflow-y: auto; - list-style: none; - margin: 0; - padding: 4px 0; - background: rgba(28, 28, 32, 0.98); - backdrop-filter: blur(12px); - border: 1px solid var(--border); + right: 0; + top: calc(100% + 4px); + z-index: 80; border-radius: 8px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); - z-index: 40; + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--shadow); + padding: 8px; + max-height: 220px; + display: flex; + flex-direction: column; + gap: 6px; } -.deck-filmstrip-source-option { +.layout-col-combo-search { + width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + color: var(--text-h); + font: inherit; + font-size: 13px; +} + +.layout-col-combo-actions { display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 7px 12px; - font-size: 12px; + justify-content: flex-end; +} + +.layout-col-combo-clear { + padding: 4px 8px; + font-size: 11px; + border-radius: 4px; + border: 1px solid var(--border); + background: transparent; color: var(--text); cursor: pointer; - transition: background 0.12s; + font: inherit; } -.deck-filmstrip-source-option:hover { - background: rgba(255, 255, 255, 0.06); +.layout-col-combo-list { + margin: 0; + padding: 0; + list-style: none; + overflow: auto; + max-height: 160px; +} + +.layout-col-combo-empty { + padding: 8px; + font-size: 12px; + color: var(--text); + opacity: 0.55; +} + +.layout-col-combo-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + padding: 8px 10px; + border: none; + border-radius: 6px; + background: transparent; color: var(--text-h); + font: inherit; + font-size: 13px; + cursor: pointer; } -.deck-filmstrip-source-option--active { - color: var(--accent); - font-weight: 500; +.layout-col-combo-option:hover { + background: rgba(255, 255, 255, 0.06); } -.deck-filmstrip-source-option-count { - font-size: 10px; - opacity: 0.6; +.layout-col-combo-option-ico { flex-shrink: 0; + opacity: 0.55; + color: #6ee7b7; } -.deck-filmstrip-scroll { - flex: 1; - min-height: 0; +.layout-fallback-label { display: flex; - flex-wrap: nowrap; - gap: 8px; - padding: 4px 12px 10px; - overflow-x: auto; - overflow-y: hidden; + flex-direction: column; + gap: 6px; + font-size: 12px; +} + +.layout-fallback-label-text { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.95); +} + +.layout-fallback-row { + display: flex; + align-items: stretch; + gap: 6px; +} + +.layout-fallback-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + color: var(--text-h); + font: inherit; + font-size: 12px; +} + +.layout-fallback-asset-btn { + flex: 0 0 auto; + display: inline-flex; align-items: center; + justify-content: center; + width: 40px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(16, 185, 129, 0.1); + color: #a7f3d0; + cursor: pointer; } -.deck-filmstrip-item { +.layout-fallback-asset-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.layout-fallback-clear-btn { flex: 0 0 auto; + width: 32px; + border-radius: 8px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: 18px; + line-height: 1; + cursor: pointer; } -.deck-filmstrip-thumb { - border-radius: 6px; - overflow: hidden; - line-height: 0; +.layout-asset-picker-backdrop { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.layout-asset-picker-dialog { + width: min(960px, 96vw); + max-height: min(88vh, 900px); + display: flex; + flex-direction: column; + border-radius: 12px; border: 1px solid var(--border); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + background: var(--bg); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.65); + overflow: hidden; } -.zone-hierarchy-heading { - margin: 12px 0 8px; - font-size: 13px; +.layout-asset-picker-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); +} + +.layout-asset-picker-title { + margin: 0; + font-size: 15px; font-weight: 600; color: var(--text-h); - letter-spacing: -0.01em; } -button.zone-tool--icon { - min-width: 32px; - padding: 0 4px; +.layout-asset-picker-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background: transparent; color: var(--text-h); + cursor: pointer; } -button.zone-tool--icon:hover:not(:disabled) { - color: var(--accent); +.layout-asset-picker-close:hover { + background: rgba(255, 255, 255, 0.08); +} + +.layout-asset-picker-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.layout-asset-picker-body .assets-shell { + flex: 1; + min-height: 0; + max-height: min(72vh, 720px); + border-top: none; +} + +.deck-filmstrip { + flex: 0 0 auto; + height: 120px; + min-height: 120px; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 8px; + background: var(--panel); + overflow: hidden; + box-sizing: border-box; +} + +.deck-filmstrip--overlay { + margin-top: 0; + min-height: 152px; + height: 152px; + max-height: min(46vh, 260px); + border-radius: 10px 10px 0 0; + border-bottom: none; + box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.55); + background: rgba(22, 22, 22, 0.97); + backdrop-filter: blur(12px); + overflow: hidden; +} + +.deck-filmstrip-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 8px 12px 4px; + flex-shrink: 0; +} + +.deck-filmstrip-title { + font-size: 12px; + font-weight: 600; + color: var(--text-h); + letter-spacing: -0.01em; +} + +.deck-filmstrip-meta { + font-size: 11px; + color: var(--text); + opacity: 0.85; +} + +.deck-filmstrip-source-picker { + position: relative; +} + +.deck-filmstrip-source-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + font-size: 11px; + font-weight: 500; + color: var(--text-h); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + transition: + background 0.15s, + border-color 0.15s; +} + +.deck-filmstrip-source-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--accent); +} + +.deck-filmstrip-source-label { + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.deck-filmstrip-source-chevron { + opacity: 0.6; + transition: transform 0.18s ease; +} + +.deck-filmstrip-source-chevron--open { + transform: rotate(180deg); +} + +.deck-filmstrip-source-menu { + position: absolute; + top: calc(100% + 4px); + bottom: auto; + left: 0; + min-width: 180px; + max-width: 260px; + max-height: min(40vh, 240px); + overflow-y: auto; + list-style: none; + margin: 0; + padding: 4px 0; + background: rgba(28, 28, 32, 0.98); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); + z-index: 40; +} + +.deck-filmstrip-source-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 12px; + font-size: 12px; + color: var(--text); + cursor: pointer; + transition: background 0.12s; +} + +.deck-filmstrip-source-option:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text-h); +} + +.deck-filmstrip-source-option--active { + color: var(--accent); + font-weight: 500; +} + +.deck-filmstrip-source-option-count { + font-size: 10px; + opacity: 0.6; + flex-shrink: 0; +} + +.deck-filmstrip-scroll { + flex: 1; + min-height: 0; + display: flex; + flex-wrap: nowrap; + gap: 8px; + padding: 4px 12px 10px; + overflow-x: auto; + overflow-y: hidden; + align-items: center; +} + +.deck-filmstrip-scroll--full { + padding: 12px 14px; + align-items: center; +} + +.deck-filmstrip-item { + flex: 0 0 auto; +} + +.deck-filmstrip-thumb { + border-radius: 6px; + overflow: hidden; + line-height: 0; + border: 1px solid var(--border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.zone-hierarchy-heading { + margin: 12px 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-h); + letter-spacing: -0.01em; +} + +button.zone-tool--icon { + min-width: 32px; + padding: 0 4px; + color: var(--text-h); +} + +button.zone-tool--icon:hover:not(:disabled) { + color: var(--accent); +} + +/* —— Project dataset (Cards tab) —— */ +.project-dataset-panel { + margin-bottom: 8px; +} + +.project-dataset-title { + margin-top: 0; +} + +/* —— Assets tab: full-viewport app shell —— */ +@keyframes assets-shell-spin { + to { + transform: rotate(360deg); + } +} + +.assets-shell-spin { + animation: assets-shell-spin 0.7s linear infinite; +} + +.assets-shell { + display: flex; + flex: 1; + min-height: 0; + width: 100%; + max-width: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.assets-shell-sidebar { + flex: 0 0 clamp(250px, 26vw, 300px); + width: clamp(250px, 26vw, 300px); + min-width: 220px; + max-width: 300px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; + background: #0c0c10; + color: rgba(244, 244, 245, 0.92); +} + +.assets-shell-sidebar-brand { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 14px 14px 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(161, 161, 170, 0.95); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.assets-shell-tree { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 0 12px; +} + +.assets-shell-tree-block + .assets-shell-tree-block { + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.assets-shell-tree-row { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 2px; + min-height: 32px; + border-radius: 6px; + min-width: 0; +} + +.assets-shell-tree-row--root:hover { + background: rgba(255, 255, 255, 0.04); +} + +.assets-shell-tree-row--scope-on { + background: rgba(255, 255, 255, 0.03); +} + +.assets-shell-tree-chev, +.assets-shell-tree-chev-spacer { + flex: 0 0 26px; + width: 26px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(161, 161, 170, 0.9); + border-radius: 4px; +} + +.assets-shell-tree-chev:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-h); +} + +.assets-shell-tree-chev-spacer { + pointer-events: none; +} + +.assets-shell-tree-label { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 8px 6px 0; + text-align: left; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + color: rgba(244, 244, 245, 0.95); +} + +.assets-shell-tree-label:hover { + background: rgba(255, 255, 255, 0.05); +} + +.assets-shell-tree-ico { + flex-shrink: 0; + opacity: 0.85; +} + +.assets-shell-tree-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-shell-tree-nested { + padding-bottom: 4px; +} + +.assets-shell-tree-root-drop, +.assets-shell-tree-leaf-drop, +.assets-shell-tree-folder-drop { + border-radius: 6px; + transition: background 0.1s ease, box-shadow 0.1s ease; +} + +.assets-shell-tree-drop-host--hover { + background: rgba(16, 185, 129, 0.1); + box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.35); +} + +.assets-shell-tree-leaf-drop .assets-shell-tree-leaf { + width: 100%; +} + +.assets-shell-folder-grip { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 28px; + margin-right: 2px; + border-radius: 4px; + cursor: grab; + color: rgba(161, 161, 170, 0.75); +} + +.assets-shell-folder-grip:hover { + color: rgba(244, 244, 245, 0.95); + background: rgba(255, 255, 255, 0.06); +} + +.assets-shell-folder-grip:active { + cursor: grabbing; +} + +.assets-shell-tree-leaf { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 8px); + margin: 1px 4px; + padding: 7px 10px; + border-radius: 6px; + border: none; + background: transparent; + font: inherit; + font-size: 13px; + color: rgba(228, 228, 231, 0.9); + cursor: pointer; + text-align: left; +} + +.assets-shell-tree-leaf:hover { + background: rgba(255, 255, 255, 0.06); +} + +.assets-shell-tree-row--active, +.assets-shell-tree-leaf.assets-shell-tree-row--active { + background: rgba(16, 185, 129, 0.14); + color: #d1fae5; +} + +.assets-shell-tree-count { + margin-left: auto; + font-size: 11px; + font-weight: 600; + opacity: 0.55; + font-variant-numeric: tabular-nums; +} + +.assets-shell-sidebar-foot { + flex: 0 0 auto; + padding: 10px 12px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 10px; +} + +.assets-shell-foot-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.assets-shell-foot-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.assets-shell-center { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.assets-shell-toolbar { + flex: 0 0 auto; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} + +.assets-shell-toolbar-search { + flex: 1; + min-width: 0; + max-width: min(560px, 55vw); + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); +} + +.assets-shell-toolbar-search-ico { + flex-shrink: 0; + opacity: 0.45; + color: var(--text); +} + +.assets-shell-search-input.cf-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + padding: 0; + height: 100%; + font-size: 13px; + box-shadow: none; +} + +.assets-shell-search-input.cf-input:focus { + outline: none; + box-shadow: none; +} + +.assets-shell-toolbar-tools { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} + +.assets-shell-toolbar-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(161, 161, 170, 0.95); + margin-right: 4px; +} + +.assets-shell-toolbar-divider { + width: 1px; + height: 22px; + background: var(--border); + margin: 0 6px; +} + +.assets-shell-tool-ico { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid transparent; + color: rgba(228, 228, 231, 0.85); + cursor: pointer; +} + +.assets-shell-tool-ico:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + border-color: var(--border); + color: var(--text-h); +} + +.assets-shell-tool-ico--on { + border-color: rgba(16, 185, 129, 0.45); + background: rgba(16, 185, 129, 0.1); + color: #a7f3d0; +} + +.assets-shell-tool-ico--danger:hover:not(:disabled) { + border-color: rgba(248, 113, 113, 0.35); + background: rgba(248, 113, 113, 0.08); + color: #fecaca; +} + +.assets-shell-gallery { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 16px 20px; + background: var(--bg); + transition: box-shadow 0.15s ease, background 0.15s ease; +} + +.assets-shell-gallery--drag-active { + background: rgba(16, 185, 129, 0.06); + box-shadow: inset 0 0 0 2px rgba(16, 185, 129, 0.35); +} + +.assets-shell-drop-ghost { + margin: 0 0 12px; + font-size: 11px; + line-height: 1.4; + color: var(--text); + opacity: 0.38; +} + +.assets-shell-drop-overlay { + position: absolute; + inset: 12px 16px 20px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + border-radius: 10px; + border: 1px dashed rgba(16, 185, 129, 0.5); + background: rgba(16, 185, 129, 0.08); + color: #a7f3d0; + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + +.assets-shell-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: 12px; + align-content: start; +} + +.assets-shell-cell { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); + cursor: pointer; + text-align: left; + overflow: hidden; +} + +.assets-shell-cell:hover { + border-color: rgba(255, 255, 255, 0.12); +} + +.assets-shell-cell--selected { + outline: 2px solid var(--accent); + outline-offset: 0; + border-color: rgba(16, 185, 129, 0.35); +} + +.assets-shell-thumb { + width: 100%; + background: #101014; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.assets-shell-thumb--square { + aspect-ratio: 1 / 1; +} + +.assets-shell-thumb--card { + aspect-ratio: 63 / 88; +} + +.assets-shell-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.assets-shell-cell-label { + font-size: 11px; + line-height: 1.3; + padding: 8px 10px; + color: var(--text-h); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-shell-no-prev { + font-size: 11px; + color: var(--text); + opacity: 0.45; + padding: 12px; +} + +.assets-shell-empty { + margin: 24px 0 0; + font-size: 13px; + color: var(--text); + opacity: 0.55; +} + +.assets-shell-inspector { + flex: 0 0 300px; + width: 300px; + min-width: 260px; + max-width: 320px; + border-left: 1px solid var(--border); + padding: 14px 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: auto; + background: rgba(0, 0, 0, 0.08); +} + +.assets-shell-inspector-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(161, 161, 170, 0.95); +} + +.assets-shell-inspector-empty { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.45; + color: var(--text); + opacity: 0.55; +} + +.assets-shell-preview { + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); + background: #0f0f12; + min-height: 120px; + max-height: 220px; + display: flex; + align-items: center; + justify-content: center; +} + +.assets-shell-preview-img { + width: 100%; + max-height: 220px; + object-fit: contain; +} + +.assets-shell-inspector-title { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + line-height: 1.35; + word-break: break-word; +} + +.assets-shell-resource-path { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + color: rgba(161, 161, 170, 0.98); + word-break: break-all; +} + +.assets-shell-meta { + margin: 0; + display: grid; + gap: 6px 10px; + grid-template-columns: auto 1fr; + font-size: 11px; +} + +.assets-shell-meta dt { + margin: 0; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.85); +} + +.assets-shell-meta dd { + margin: 0; + min-width: 0; +} + +.assets-shell-meta-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + line-height: 1.45; + color: rgba(161, 161, 170, 0.95); + opacity: 0.9; + word-break: break-all; +} + +.assets-shell-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.assets-shell-field-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.9); +} + +.assets-shell-select { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.22); + color: var(--text-h); + font: inherit; + font-size: 12px; +} + +.assets-shell-promote { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(16, 185, 129, 0.45); + background: rgba(16, 185, 129, 0.1); + color: #a7f3d0; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.assets-shell-promote:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.18); +} + +.assets-shell-usage-block { + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.assets-shell-usage-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(161, 161, 170, 0.95); + margin-bottom: 8px; +} + +.assets-shell-usage-empty { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--text); + opacity: 0.5; +} + +.assets-shell-usage-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.assets-shell-usage-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); + font-size: 12px; + color: var(--text-h); +} + +.assets-shell-usage-list li span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-shell-usage-li-ico { + flex-shrink: 0; + opacity: 0.55; +} + +.mono.small { + font-size: 11px; + word-break: break-all; +} + +@media (max-width: 960px) { + .assets-shell { + flex-direction: column; + overflow: auto; + } + + .assets-shell-sidebar { + flex: 0 0 auto; + width: 100%; + max-width: none; + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 42vh; + } + + .assets-shell-center { + flex: 1 1 auto; + min-height: 280px; + border-right: none; + } + + .assets-shell-inspector { + flex: 0 0 auto; + width: 100%; + max-width: none; + border-left: none; + margin-left: 0; + border-top: 1px solid var(--border); + } } /* —— Global toasts (bottom of viewport) —— */ diff --git a/frontend/src/components/AssetsTabPanel.tsx b/frontend/src/components/AssetsTabPanel.tsx new file mode 100644 index 0000000..b71b658 --- /dev/null +++ b/frontend/src/components/AssetsTabPanel.tsx @@ -0,0 +1,916 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Box, + ChevronDown, + ChevronRight, + Folder, + FolderPlus, + Globe, + GripVertical, + ImageOff, + LayoutTemplate, + Library, + Loader2, + Ratio, + Search, + Square, + Trash2, + UploadCloud, +} from 'lucide-react'; +import { apiBase } from '../lib/api'; +import { + loadAssetFolderStore, + newFolderId, + saveAssetFolderStore, + type AssetFolderScope, + type AssetFolderStore, + type StoredAssetFolder, +} from '../lib/assetFolderStorage'; +import { normalizeArtLookupKey } from '../lib/assetResolve'; +import { collectArtKeysFromLayoutState } from '../lib/layoutArtKeys'; +import { Input } from './ui/input'; + +export type StudioAssetRow = { + id: string; + artKey: string; + s3Key: string; + url?: string; +}; + +type LayoutLite = { id: string; name: string; state: unknown }; + +type Props = { + projectId: string; + token: string | null; + busy: boolean; + projectAssets: StudioAssetRow[]; + globalAssets: StudioAssetRow[]; + layoutsFull: LayoutLite[]; + onRefresh: () => void; + onError: (msg: string) => void; + /** When set, clicking an asset in the grid picks it (e.g. layout editor fallback modal). */ + artPickerMode?: boolean; + onArtKeyPicked?: (artKey: string) => void; +}; + +type ViewKind = + | { kind: 'all' } + | { kind: 'unused' } + | { kind: 'folder'; folderId: string }; + +type TreeNav = { + scope: AssetFolderScope; + view: ViewKind; +}; + +const ASSET_DRAG_MIME = 'application/x-cardgoose-asset'; +const FOLDER_DRAG_MIME = 'application/x-cardgoose-folder'; + +function assignKey(scope: AssetFolderScope, assetId: string) { + return `${scope}:${assetId}`; +} + +function foldersByScope(store: AssetFolderStore, scope: AssetFolderScope): StoredAssetFolder[] { + return store.folders.filter((f) => f.scope === scope); +} + +function folderAncestorChainNames( + store: AssetFolderStore, + scope: AssetFolderScope, + folderId: string +): string[] { + const byId = new Map(foldersByScope(store, scope).map((f) => [f.id, f])); + const names: string[] = []; + let cur: string | null = folderId; + while (cur) { + const f = byId.get(cur); + if (!f) break; + names.push(f.name); + cur = f.parentId; + } + names.reverse(); + return names; +} + +/** Virtual path from folder hierarchy + art key, e.g. `/test_folder/will.png` */ +function virtualAssetPath(asset: StudioAssetRow, scope: AssetFolderScope, store: AssetFolderStore): string { + const fid = store.assignments[assignKey(scope, asset.id)]; + const segs = fid ? folderAncestorChainNames(store, scope, fid) : []; + const leaf = asset.artKey.replace(/^\/+/, ''); + if (segs.length === 0) return `/${leaf}`; + return `/${segs.join('/')}/${leaf}`; +} + +/** True if `ancestorId` appears on the parent chain above `folderId` (cannot reparent `ancestorId` into `folderId`). */ +function folderHasAncestor( + store: AssetFolderStore, + scope: AssetFolderScope, + folderId: string, + ancestorId: string +): boolean { + const byId = new Map(foldersByScope(store, scope).map((f) => [f.id, f])); + let cur = byId.get(folderId)?.parentId ?? null; + while (cur) { + if (cur === ancestorId) return true; + cur = byId.get(cur)?.parentId ?? null; + } + return false; +} + +type TreeDropTarget = + | { kind: 'folder'; scope: AssetFolderScope; folderId: string } + | { kind: 'library-root'; scope: AssetFolderScope }; + +export function AssetsTabPanel({ + projectId, + token, + busy, + projectAssets, + globalAssets, + layoutsFull, + onRefresh, + onError, + artPickerMode = false, + onArtKeyPicked, +}: Props) { + const [search, setSearch] = useState(''); + const [nav, setNav] = useState({ scope: 'project', view: { kind: 'all' } }); + const [selection, setSelection] = useState>(new Set()); + const [thumbRatio, setThumbRatio] = useState<'square' | 'card'>('square'); + const [dropHover, setDropHover] = useState(null); + const [folderStore, setFolderStore] = useState(() => + loadAssetFolderStore(projectId) + ); + const [expandedLibrary, setExpandedLibrary] = useState>( + () => new Set(['project', 'global']) + ); + const [expandedFolders, setExpandedFolders] = useState>(() => new Set()); + const [dragDepth, setDragDepth] = useState(0); + const [isDraggingFiles, setIsDraggingFiles] = useState(false); + + useEffect(() => { + setFolderStore(loadAssetFolderStore(projectId)); + }, [projectId]); + + useEffect(() => { + saveAssetFolderStore(projectId, folderStore); + }, [projectId, folderStore]); + + const persistFolderStore = useCallback((updater: (prev: AssetFolderStore) => AssetFolderStore) => { + setFolderStore(updater); + }, []); + + const filter = useCallback( + (rows: StudioAssetRow[]) => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => + r.artKey.toLowerCase().includes(q) || + r.s3Key.toLowerCase().includes(q) || + normalizeArtLookupKey(r.artKey).includes(q) + ); + }, + [search] + ); + + const visibleProject = useMemo(() => filter(projectAssets), [filter, projectAssets]); + const visibleGlobal = useMemo(() => filter(globalAssets), [filter, globalAssets]); + + const usedNormalizedKeys = useMemo(() => { + const s = new Set(); + for (const L of layoutsFull) { + for (const k of collectArtKeysFromLayoutState(L.state)) { + s.add(normalizeArtLookupKey(k)); + } + } + return s; + }, [layoutsFull]); + + const unusedProject = useMemo( + () => visibleProject.filter((a) => !usedNormalizedKeys.has(normalizeArtLookupKey(a.artKey))), + [visibleProject, usedNormalizedKeys] + ); + + const gridItems = useMemo((): StudioAssetRow[] => { + const base = nav.scope === 'project' ? visibleProject : visibleGlobal; + if (nav.view.kind === 'all') return base; + if (nav.view.kind === 'unused') { + return nav.scope === 'project' ? unusedProject : []; + } + const fid = nav.view.folderId; + return base.filter((a) => folderStore.assignments[assignKey(nav.scope, a.id)] === fid); + }, [nav, visibleProject, visibleGlobal, unusedProject, folderStore.assignments]); + + const selectAsset = (asset: StudioAssetRow) => { + const prefix = nav.scope === 'project' ? 'p' : 'g'; + const idKey = `${prefix}:${asset.id}`; + setSelection((prev) => { + if (prev.size === 1 && prev.has(idKey)) return new Set(); + return new Set([idKey]); + }); + }; + + const selectedProjectAsset = useMemo(() => { + for (const id of selection) { + if (!id.startsWith('p:')) continue; + const rid = id.slice(2); + const a = projectAssets.find((x) => x.id === rid); + if (a) return a; + } + return null; + }, [selection, projectAssets]); + + const selectedGlobalAsset = useMemo(() => { + for (const id of selection) { + if (!id.startsWith('g:')) continue; + const rid = id.slice(2); + const a = globalAssets.find((x) => x.id === rid); + if (a) return a; + } + return null; + }, [selection, globalAssets]); + + const selectedAsset = selectedProjectAsset ?? selectedGlobalAsset; + + const usageLayouts = useMemo(() => { + const sel = selectedAsset; + if (!sel) return []; + const nk = normalizeArtLookupKey(sel.artKey); + const out: { id: string; name: string }[] = []; + for (const L of layoutsFull) { + const keys = collectArtKeysFromLayoutState(L.state); + if (keys.some((k) => normalizeArtLookupKey(k) === nk)) { + out.push({ id: L.id, name: L.name }); + } + } + return out; + }, [selectedAsset, layoutsFull]); + + const childFolders = useCallback( + (scope: AssetFolderScope, parentId: string | null) => + folderStore.folders.filter((f) => f.scope === scope && f.parentId === parentId), + [folderStore.folders] + ); + + const setAssetFolderAssignment = useCallback( + (scope: AssetFolderScope, assetId: string, folderId: string | '') => { + const k = assignKey(scope, assetId); + persistFolderStore((prev) => { + const assignments = { ...prev.assignments }; + if (!folderId) delete assignments[k]; + else assignments[k] = folderId; + return { ...prev, assignments }; + }); + }, + [persistFolderStore] + ); + + const selectedAssetScope: AssetFolderScope | null = selectedProjectAsset + ? 'project' + : selectedGlobalAsset + ? 'global' + : null; + + const selectedVirtualPath = useMemo(() => { + if (!selectedAsset || !selectedAssetScope) return ''; + return virtualAssetPath(selectedAsset, selectedAssetScope, folderStore); + }, [selectedAsset, selectedAssetScope, folderStore]); + + const treeAcceptsInternalDrag = (e: React.DragEvent) => + e.dataTransfer.types.includes(ASSET_DRAG_MIME) || e.dataTransfer.types.includes(FOLDER_DRAG_MIME); + + const leaveDropHost = (e: React.DragEvent) => { + const cur = e.currentTarget as HTMLElement; + const rel = e.relatedTarget as Node | null; + if (rel && cur.contains(rel)) return; + setDropHover(null); + }; + + const handleTreeDrop = useCallback( + (e: React.DragEvent, target: TreeDropTarget) => { + e.preventDefault(); + e.stopPropagation(); + setDropHover(null); + let raw = e.dataTransfer.getData(ASSET_DRAG_MIME); + if (raw) { + try { + const { scope, assetId } = JSON.parse(raw) as { scope: AssetFolderScope; assetId: string }; + if (target.scope !== scope) return; + if (target.kind === 'folder') { + setAssetFolderAssignment(scope, assetId, target.folderId); + } else { + setAssetFolderAssignment(scope, assetId, ''); + } + } catch { + /* */ + } + return; + } + raw = e.dataTransfer.getData(FOLDER_DRAG_MIME); + if (!raw) return; + try { + const { scope, folderId } = JSON.parse(raw) as { scope: AssetFolderScope; folderId: string }; + if (target.scope !== scope) return; + if (target.kind === 'folder') { + if (folderId === target.folderId) return; + persistFolderStore((prev) => { + if (folderHasAncestor(prev, scope, target.folderId, folderId)) return prev; + return { + ...prev, + folders: prev.folders.map((f) => + f.id === folderId && f.scope === scope ? { ...f, parentId: target.folderId } : f + ), + }; + }); + } else { + persistFolderStore((prev) => ({ + ...prev, + folders: prev.folders.map((f) => + f.id === folderId && f.scope === scope ? { ...f, parentId: null } : f + ), + })); + } + } catch { + /* */ + } + }, + [setAssetFolderAssignment, persistFolderStore] + ); + + const onAssetDragStart = (e: React.DragEvent, scope: AssetFolderScope, assetId: string) => { + e.dataTransfer.setData(ASSET_DRAG_MIME, JSON.stringify({ scope, assetId })); + e.dataTransfer.effectAllowed = 'move'; + }; + + const onFolderDragStart = (e: React.DragEvent, scope: AssetFolderScope, folderId: string) => { + e.dataTransfer.setData(FOLDER_DRAG_MIME, JSON.stringify({ scope, folderId })); + e.dataTransfer.effectAllowed = 'move'; + }; + + async function uploadFiles(files: FileList | null, target: AssetFolderScope) { + if (!token || !files?.length) return; + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append('file', file); + const path = + target === 'project' + ? `${apiBase()}/api/projects/${projectId}/assets` + : `${apiBase()}/api/user/global-assets`; + const res = await fetch(path, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + onError((data as { error?: string })?.error ?? res.statusText); + return; + } + } + onRefresh(); + } + + async function promoteSelected() { + if (!token || !selectedProjectAsset) return; + const res = await fetch( + `${apiBase()}/api/projects/${projectId}/assets/${selectedProjectAsset.id}/promote-global`, + { method: 'POST', headers: { Authorization: `Bearer ${token}` } } + ); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + onError((data as { error?: string })?.error ?? res.statusText); + return; + } + setSelection(new Set()); + onRefresh(); + } + + async function deleteSelected() { + if (!token || selection.size === 0) return; + if (!window.confirm(`Delete ${selection.size} asset(s)?`)) return; + for (const id of selection) { + if (id.startsWith('p:')) { + const res = await fetch(`${apiBase()}/api/projects/${projectId}/assets/${id.slice(2)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const t = await res.text(); + let err = res.statusText; + try { + err = JSON.parse(t).error ?? err; + } catch { + /* */ + } + onError(err); + return; + } + } else if (id.startsWith('g:')) { + const res = await fetch(`${apiBase()}/api/user/global-assets/${id.slice(2)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + onError('Delete global asset failed'); + return; + } + } + } + setSelection(new Set()); + onRefresh(); + } + + const onGridDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + if (!e.dataTransfer.types.includes('Files')) return; + setDragDepth((d) => d + 1); + setIsDraggingFiles(true); + }; + + const onGridDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setDragDepth((d) => { + const next = Math.max(0, d - 1); + if (next === 0) setIsDraggingFiles(false); + return next; + }); + }; + + const onGridDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.types.includes('Files')) { + e.dataTransfer.dropEffect = 'copy'; + } + }; + + const onGridDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragDepth(0); + setIsDraggingFiles(false); + void uploadFiles(e.dataTransfer.files, nav.scope); + }; + + const toggleLibrary = (scope: AssetFolderScope) => { + setExpandedLibrary((prev) => { + const next = new Set(prev); + if (next.has(scope)) next.delete(scope); + else next.add(scope); + return next; + }); + }; + + const toggleFolderExpand = (folderId: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderId)) next.delete(folderId); + else next.add(folderId); + return next; + }); + }; + + const newFolderUnderActive = () => { + const name = window.prompt('Folder name', 'New folder')?.trim(); + if (!name) return; + const scope = nav.scope; + let parentId: string | null = null; + if (nav.view.kind === 'folder') parentId = nav.view.folderId; + const id = newFolderId(); + persistFolderStore((prev) => ({ + ...prev, + folders: [...prev.folders, { id, parentId, name, scope }], + })); + setExpandedLibrary((s) => new Set(s).add(scope)); + if (parentId) { + setExpandedFolders((s) => new Set(s).add(parentId)); + } + setNav({ scope, view: { kind: 'folder', folderId: id } }); + }; + + const dropKeyFolder = (scope: AssetFolderScope, folderId: string) => `folder:${scope}:${folderId}`; + + const renderFolderSubtree = (scope: AssetFolderScope, parentId: string | null, depth: number) => { + const rows = childFolders(scope, parentId) + .slice() + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + return rows.map((f) => { + const isOpen = expandedFolders.has(f.id); + const hasChildren = childFolders(scope, f.id).length > 0; + const active = + nav.scope === scope && nav.view.kind === 'folder' && nav.view.folderId === f.id; + const dk = dropKeyFolder(scope, f.id); + return ( +
+
setDropHover(dk)} + onDragOver={(e) => { + if (treeAcceptsInternalDrag(e)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }} + onDragLeave={leaveDropHost} + onDrop={(e) => handleTreeDrop(e, { kind: 'folder', scope, folderId: f.id })} + > +
+ { + e.stopPropagation(); + onFolderDragStart(e, scope, f.id); + }} + title="Drag to move folder" + role="presentation" + > + + + {hasChildren ? ( + + ) : ( + + )} + +
+
+ {isOpen && hasChildren && ( +
{renderFolderSubtree(scope, f.id, depth + 1)}
+ )} +
+ ); + }); + }; + + const thumbClass = + thumbRatio === 'square' + ? 'assets-shell-thumb assets-shell-thumb--square' + : 'assets-shell-thumb assets-shell-thumb--card'; + + return ( +
+ + +
+
+
+ + setSearch(e.target.value)} + autoComplete="off" + /> +
+
+ Thumbnails + + + + +
+
+ +
+ {!isDraggingFiles && ( +

+ Drag files here to upload to the {nav.scope === 'project' ? 'project' : 'global'} library. +

+ )} + {isDraggingFiles && ( +
+ + Drop to upload +
+ )} +
+ {gridItems.map((a) => { + const idKey = `${nav.scope === 'project' ? 'p' : 'g'}:${a.id}`; + const selected = selection.has(idKey); + return ( + + ); + })} +
+ {gridItems.length === 0 && !isDraggingFiles && ( +

+ {nav.view.kind === 'folder' + ? 'This folder is empty. Drag assets here from the gallery, or choose another folder.' + : 'No assets match this view.'} +

+ )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/CardFace.tsx b/frontend/src/components/CardFace.tsx index 7e63af6..8870198 100644 --- a/frontend/src/components/CardFace.tsx +++ b/frontend/src/components/CardFace.tsx @@ -2,6 +2,7 @@ import { memo, type Ref } from 'react'; import type { Layer as KonvaLayer } from 'konva/lib/Layer'; import { Group as KonvaGroup, Image as KonvaImage, Layer, Rect, Stage, Text } from 'react-konva'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; +import { smartResolveLayoutImageUrl } from '../lib/assetResolve'; import { applyTemplate } from '../lib/template'; import { isVisible } from '../lib/layoutTree'; import { useImageElement } from './useImageElement'; @@ -47,12 +48,16 @@ function TextEl({ function ImageEl({ el, + row, assetUrls, + assetResolveOrder, }: { el: Extract; + row: Record; assetUrls: Record; + assetResolveOrder: string[]; }) { - const url = assetUrls[el.artKey]; + const url = smartResolveLayoutImageUrl(el, row, assetUrls, assetResolveOrder); const img = useImageElement(url); if (!img) { return ( @@ -84,15 +89,17 @@ function CardNode({ node, row, assetUrls, + assetResolveOrder, }: { node: LayoutElement; row: Record; assetUrls: Record; + assetResolveOrder: string[]; }) { if (!isVisible(node)) return null; if (node.type === 'rect') return ; if (node.type === 'text') return ; - return ; + return ; } function rowDataEqual(a: Record, b: Record): boolean { @@ -109,12 +116,21 @@ type CardFaceProps = { state: LayoutStateV2; row: Record; assetUrls: Record; + /** Project art keys first, then global — used for fuzzy cell→asset matching. */ + assetResolveOrder?: string[]; pixelWidth: number; /** For headless export: observe draw completion */ layerRef?: Ref; }; -function CardFaceInner({ state, row, assetUrls, pixelWidth, layerRef }: CardFaceProps) { +function CardFaceInner({ + state, + row, + assetUrls, + assetResolveOrder = [], + pixelWidth, + layerRef, +}: CardFaceProps) { const scale = pixelWidth / state.width; const pixelHeight = state.height * scale; const bg = state.background ?? '#1e1e24'; @@ -125,7 +141,13 @@ function CardFaceInner({ state, row, assetUrls, pixelWidth, layerRef }: CardFace {state.root.map((node) => ( - + ))} @@ -139,6 +161,7 @@ export const CardFace = memo(CardFaceInner, (prev, next) => { prev.state === next.state && prev.layerRef === next.layerRef && prev.assetUrls === next.assetUrls && + prev.assetResolveOrder === next.assetResolveOrder && rowDataEqual(prev.row, next.row) ); }); diff --git a/frontend/src/components/CardGroupsPanel.tsx b/frontend/src/components/CardGroupsPanel.tsx index 083456a..1c6ca71 100644 --- a/frontend/src/components/CardGroupsPanel.tsx +++ b/frontend/src/components/CardGroupsPanel.tsx @@ -88,9 +88,12 @@ export function CardGroupsPanel(props: { token: string | null; layoutsFull: LayoutFull[]; assetUrls: Record; - projectCsvSourceUrl: string | null; + /** Project art keys first, then global — for layout image fuzzy matching in previews. */ + assetResolveOrder?: string[]; /** Project-wide busy (e.g. layout save); card-group mutations use internal state so previews don’t thrash. */ busy: boolean; + /** Fired when group list implies at least one published CSV URL (for studio chrome). */ + onAnyPublishedUrlChange?: (hasAny: boolean) => void; onError: (msg: string | null) => void; onOpenLayoutInEditor: (layoutId: string) => void; }) { @@ -99,8 +102,9 @@ export function CardGroupsPanel(props: { token, layoutsFull, assetUrls, - projectCsvSourceUrl, + assetResolveOrder = [], busy, + onAnyPublishedUrlChange, onError, onOpenLayoutInEditor, } = props; @@ -140,6 +144,10 @@ export function CardGroupsPanel(props: { void loadGroups(); }, [loadGroups]); + useEffect(() => { + onAnyPublishedUrlChange?.(groups.some((g) => Boolean(g.csvSourceUrl?.trim()))); + }, [groups, onAnyPublishedUrlChange]); + useEffect(() => { if (editingTitleId && titleInputRef.current) { titleInputRef.current.focus(); @@ -482,21 +490,6 @@ export function CardGroupsPanel(props: { }} /> - {projectCsvSourceUrl ? ( - - ) : null}
diff --git a/frontend/src/components/ColumnSearchCombobox.tsx b/frontend/src/components/ColumnSearchCombobox.tsx new file mode 100644 index 0000000..3f1a74a --- /dev/null +++ b/frontend/src/components/ColumnSearchCombobox.tsx @@ -0,0 +1,112 @@ +import { Braces, ChevronsUpDown } from 'lucide-react'; +import { useEffect, useId, useRef, useState } from 'react'; + +type Props = { + label: string; + options: string[]; + value: string; + onChange: (column: string) => void; + placeholder?: string; +}; + +export function ColumnSearchCombobox({ + label, + options, + value, + onChange, + placeholder = 'Search columns…', +}: Props) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(''); + const rootRef = useRef(null); + const listId = useId(); + const qLower = q.trim().toLowerCase(); + const filtered = qLower + ? options.filter((o) => o.toLowerCase().includes(qLower)) + : options; + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + return ( +
+ {label} + + {open && ( +
+ setQ(e.target.value)} + placeholder={placeholder} + autoComplete="off" + autoFocus + /> +
+ +
+
    + {filtered.length === 0 ? ( +
  • No matching columns.
  • + ) : ( + filtered.map((o) => ( +
  • + +
  • + )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ExportTabPanel.tsx b/frontend/src/components/ExportTabPanel.tsx new file mode 100644 index 0000000..b282eb1 --- /dev/null +++ b/frontend/src/components/ExportTabPanel.tsx @@ -0,0 +1,98 @@ +import { Loader2 } from 'lucide-react'; + +type ExportRow = { key: string; url: string }; + +type ExportTabPanelProps = { + busy: boolean; + exportPdfLoading: boolean; + exportPdfStatus: string | null; + exportPdfDpi: number; + onExportPdfDpiChange: (dpi: number) => void; + onExportPdf: () => void; + exports: ExportRow[]; +}; + +export function ExportTabPanel({ + busy, + exportPdfLoading, + exportPdfStatus, + exportPdfDpi, + onExportPdfDpiChange, + onExportPdf, + exports, +}: ExportTabPanelProps) { + return ( +
+
+

Export PDF

+

+ Enqueues on SQS — the request finishes quickly while a worker renders the PDF. Run{' '} + python -m baker.main (or your ECS worker) with RENDER_URL set + to this dev server. +

+ +
+ + {exportPdfLoading && ( + + Sending to queue… + + )} +
+ {exportPdfStatus && !exportPdfLoading && ( +

+ {exportPdfStatus} +

+ )} +
+ +
+

Completed exports

+ + {exports.length === 0 && ( +

+ No exports yet — run the worker with RENDER_URL set, or deploy to ECS. +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/LayoutEditor.tsx b/frontend/src/components/LayoutEditor.tsx index f3be8a1..77aba42 100644 --- a/frontend/src/components/LayoutEditor.tsx +++ b/frontend/src/components/LayoutEditor.tsx @@ -22,11 +22,14 @@ import { Text, Transformer, } from 'react-konva'; -import { ChevronDown, Database, Layers, Minus, Plus } from 'lucide-react'; +import { Database, Image as ImageLucide, Layers, Minus, Plus, Table2, X } from 'lucide-react'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; import { DEFAULT_NEW_TEXT } from '../types/layout'; +import { normalizeArtLookupKey, smartResolveLayoutImageUrl } from '../lib/assetResolve'; import { applyTemplate } from '../lib/template'; +import { AssetsTabPanel, type StudioAssetRow } from './AssetsTabPanel'; import { CardFace } from './CardFace'; +import { ColumnSearchCombobox } from './ColumnSearchCombobox'; import { useImageElement } from './useImageElement'; import { LayoutEditorFooterButton, LayoutEditorFooterValueStrip } from './LayoutEditorFooterButton'; import { ZoneHierarchy, type ZoneHierarchyToolbarProps } from './ZoneHierarchy'; @@ -153,7 +156,9 @@ function TextEditorBlock({ function ImageShape({ el, + sampleRow, assetUrls, + orderedArtKeys, selected, setNodeRef, onSelect, @@ -161,14 +166,16 @@ function ImageShape({ onTransformEnd, }: { el: Extract; + sampleRow: Record; assetUrls: Record; + orderedArtKeys: string[]; selected: boolean; setNodeRef: (id: string, node: Konva.Node | null) => void; onSelect: (id: string) => void; onDragEnd: (e: KonvaEventObject) => void; onTransformEnd: (e: KonvaEventObject) => void; }) { - const url = assetUrls[el.artKey]; + const url = smartResolveLayoutImageUrl(el, sampleRow, assetUrls, orderedArtKeys); const img = useImageElement(url); const common = { id: el.id, @@ -183,9 +190,11 @@ function ImageShape({ onDragEnd, onTransformEnd, }; + const urlKey = url ?? ''; if (!img) { return ( setNodeRef(el.id, r)} {...common} fill="#2a2a32" @@ -196,6 +205,7 @@ function ImageShape({ } return ( setNodeRef(el.id, r)} {...common} image={img} @@ -209,6 +219,7 @@ function EditorNode({ node, selectedId, assetUrls, + orderedArtKeys, sampleRow, setNodeRef, onSelect, @@ -218,6 +229,7 @@ function EditorNode({ node: LayoutElement; selectedId: string | null; assetUrls: Record; + orderedArtKeys: string[]; sampleRow: Record; setNodeRef: (id: string, node: Konva.Node | null) => void; onSelect: (id: string) => void; @@ -309,7 +321,9 @@ function EditorNode({ return ( 36 ? `${t.slice(0, 36)}…` : t || 'Text'; } - if (node.type === 'image') return node.artKey || 'Image'; + if (node.type === 'image') { + const c = node.dynamicSourceColumn?.trim(); + if (c) return `{{${c}}}`; + return node.fallbackArtKey || node.artKey || 'Image'; + } return 'Shape'; } @@ -381,29 +399,78 @@ export type LayoutEditorHandle = { zoomTo100Percent: () => void; }; -export type EditorDataSource = { +export type DeckPreviewOption = { id: string; label: string; rows: Record[]; + /** CSV header row — use when row objects omit keys (e.g. empty first row). */ + headers?: string[]; + /** Card group’s linked layout id, or null for project dataset / sample. */ + layoutId: string | null; }; +function defaultPreviewSourceId( + activeLayoutId: string | undefined, + options: DeckPreviewOption[] +): string { + if (options.length === 0) return '__sample__'; + if (activeLayoutId) { + const linked = options.find((o) => o.layoutId === activeLayoutId); + if (linked) return linked.id; + } + const withRows = options.find((o) => o.rows.length > 0); + if (withRows) return withRows.id; + return options[0].id; +} + +type LayoutLite = { id: string; name: string; state: unknown }; + type LayoutEditorProps = { state: LayoutStateV2; onChange: (next: LayoutStateV2) => void; assetUrls: Record; sampleRow: Record; - deckRows?: Record[]; - dataSources?: EditorDataSource[]; + deckPreviewOptions: DeckPreviewOption[]; + activeLayoutId?: string | null; onCapabilitiesChange?: (c: { canUndo: boolean; canRedo: boolean }) => void; + /** Fuzzy resolver: project art keys first, then global. */ + projectAssetArtKeys?: string[]; + globalAssetArtKeys?: string[]; + projectId?: string | null; + token?: string | null; + layoutsFull?: LayoutLite[]; + projectAssets?: StudioAssetRow[]; + globalAssets?: StudioAssetRow[]; + onStudioAssetsRefresh?: () => void; }; export const LayoutEditor = forwardRef(function LayoutEditor( - { state, onChange, assetUrls, sampleRow, deckRows = [], dataSources = [], onCapabilitiesChange }, + { + state, + onChange, + assetUrls, + sampleRow, + deckPreviewOptions, + activeLayoutId, + onCapabilitiesChange, + projectAssetArtKeys = [], + globalAssetArtKeys = [], + projectId = null, + token = null, + layoutsFull = [], + projectAssets = [], + globalAssets = [], + onStudioAssetsRefresh, + }, ref ) { const [selectedId, setSelectedId] = useState(null); - const [selectedDataSourceId, setSelectedDataSourceId] = useState(null); - const [dataSourceMenuOpen, setDataSourceMenuOpen] = useState(false); + const [previewSourceId, setPreviewSourceId] = useState(() => + defaultPreviewSourceId(activeLayoutId ?? undefined, deckPreviewOptions) + ); + const [previewSourceMenuOpen, setPreviewSourceMenuOpen] = useState(false); + const previewSourceMenuRef = useRef(null); + const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const historyPast = useRef([]); @@ -464,6 +531,12 @@ export const LayoutEditor = forwardRef(fu setZoomPercent((p) => clampZoomPercent(p - ZOOM_STEP_FINE)); }, []); + const deleteSelected = useCallback(() => { + if (!selectedId) return; + commit({ ...state, root: removeNodeById(state.root, selectedId) }); + setSelectedId(null); + }, [selectedId, state, commit]); + useImperativeHandle( ref, () => ({ @@ -490,10 +563,26 @@ export const LayoutEditor = forwardRef(fu onCapabilitiesChange?.({ canUndo, canRedo }); }, [canUndo, canRedo, onCapabilitiesChange]); + useEffect(() => { + setPreviewSourceId((prev) => + deckPreviewOptions.some((o) => o.id === prev) + ? prev + : defaultPreviewSourceId(activeLayoutId ?? undefined, deckPreviewOptions) + ); + }, [deckPreviewOptions, activeLayoutId]); + useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el?.closest('input, textarea, select, [contenteditable="true"]')) return; + + if (e.code === 'Delete' || e.code === 'Backspace') { + if (!selectedId) return; + e.preventDefault(); + deleteSelected(); + return; + } + const meta = e.metaKey || e.ctrlKey; if (!meta) return; @@ -525,7 +614,7 @@ export const LayoutEditor = forwardRef(fu }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [undo, redo, zoomToFit, zoomTo100Percent, zoomInFromMenu, zoomOutFromMenu]); + }, [undo, redo, zoomToFit, zoomTo100Percent, zoomInFromMenu, zoomOutFromMenu, selectedId, deleteSelected]); useLayoutEffect(() => { const el = canvasFillRef.current; @@ -601,7 +690,7 @@ export const LayoutEditor = forwardRef(fu const scale = fitScale * (zoomPercent / 100); const stageW = state.width * scale; const stageH = state.height * scale; - const showGrid = state.showGrid !== false; + const showGrid = state.showGrid ?? false; const toggleVisible = (id: string) => { commit( @@ -666,6 +755,8 @@ export const LayoutEditor = forwardRef(fu width: Math.min(120, state.width - 80), height: 120, artKey: 'art', + dynamicSourceColumn: null, + fallbackArtKey: null, visible: true, locked: false, }; @@ -680,11 +771,7 @@ export const LayoutEditor = forwardRef(fu commit({ ...state, root: insertAfterSiblingDeep(state.root, selectedId, dup) }); setSelectedId(dup.id); }, - onRemove: () => { - if (!selectedId) return; - commit({ ...state, root: removeNodeById(state.root, selectedId) }); - setSelectedId(null); - }, + onRemove: deleteSelected, onUndo: () => undo(), onRedo: () => redo(), onToggleGrid: () => commit({ ...state, showGrid: !showGrid }), @@ -693,19 +780,54 @@ export const LayoutEditor = forwardRef(fu showGrid, hasSelection: !!selectedId, }), - [state, selectedId, commit, undo, redo, canUndo, canRedo, showGrid] + [state, selectedId, commit, undo, redo, canUndo, canRedo, showGrid, deleteSelected] ); - const activeSource = useMemo( - () => (selectedDataSourceId ? dataSources.find((s) => s.id === selectedDataSourceId) : null), - [selectedDataSourceId, dataSources] - ); - const effectiveRows = activeSource ? activeSource.rows : deckRows; - const effectiveSampleRow = activeSource ? (activeSource.rows[0] ?? {}) : sampleRow; + const activePreviewOption = useMemo(() => { + if (deckPreviewOptions.length === 0) return undefined; + return deckPreviewOptions.find((o) => o.id === previewSourceId) ?? deckPreviewOptions[0]; + }, [deckPreviewOptions, previewSourceId]); + const effectiveRows = activePreviewOption?.rows ?? []; + const effectiveSampleRow = effectiveRows[0] ?? {}; const filmstripRows = effectiveRows.length > 0 ? effectiveRows : [{}]; + const dataColumnHeaders = useMemo(() => { + const s = new Set(); + const hdrs = activePreviewOption?.headers; + if (Array.isArray(hdrs)) { + for (const h of hdrs) { + if (typeof h === 'string' && h.trim()) s.add(h.trim()); + } + } + for (const r of effectiveRows) { + if (r && typeof r === 'object') { + for (const k of Object.keys(r)) { + if (k.trim()) s.add(k.trim()); + } + } + } + return [...s].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + }, [effectiveRows, activePreviewOption?.headers]); + + const orderedArtKeys = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const k of projectAssetArtKeys) { + const n = normalizeArtLookupKey(k); + if (seen.has(n)) continue; + seen.add(n); + out.push(k); + } + for (const k of globalAssetArtKeys) { + const n = normalizeArtLookupKey(k); + if (seen.has(n)) continue; + seen.add(n); + out.push(k); + } + return out; + }, [projectAssetArtKeys, globalAssetArtKeys]); + const [deckPreviewOpen, setDeckPreviewOpen] = useState(false); const deckDrawerId = useId(); - const dataSourceMenuRef = useRef(null); const zoomOut = useCallback((e: MouseEvent) => { const step = e.shiftKey ? ZOOM_STEP_COARSE : ZOOM_STEP_FINE; @@ -723,15 +845,18 @@ export const LayoutEditor = forwardRef(fu const bgColorInputRef = useRef(null); useEffect(() => { - if (!dataSourceMenuOpen) return; + if (!previewSourceMenuOpen) return; const onClick = (e: Event) => { - if (dataSourceMenuRef.current && !dataSourceMenuRef.current.contains(e.target as Node)) { - setDataSourceMenuOpen(false); + if ( + previewSourceMenuRef.current && + !previewSourceMenuRef.current.contains(e.target as Node) + ) { + setPreviewSourceMenuOpen(false); } }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); - }, [dataSourceMenuOpen]); + }, [previewSourceMenuOpen]); const commitZoomInput = useCallback(() => { const raw = zoomInputDraft.trim(); @@ -837,16 +962,58 @@ export const LayoutEditor = forwardRef(fu )} {selected?.type === 'image' && ( <> -
- Image -
-