diff --git a/apps/studio/api/_r2.js b/apps/studio/api/_r2.js index 9d4bdf85..d786c5a5 100644 --- a/apps/studio/api/_r2.js +++ b/apps/studio/api/_r2.js @@ -1,4 +1,4 @@ -import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export const r2Bucket = String(process.env.R2_BUCKET_NAME || 'saga-studio-media').trim(); @@ -18,16 +18,25 @@ export function getR2Client() { } export function isSourceKey(key) { - return /^sources\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || '')); + return /^(?:sources|uploads)\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || '')); } -export async function createSourceUploadUrl({ key, contentType, expiresIn = 300 }) { +export function isLibraryUploadKey(key) { + return /^uploads\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$/i.test(String(key || '')); +} + +function requireClient() { const client = getR2Client(); if (!client) { const error = new Error('R2 storage is not configured'); error.statusCode = 503; throw error; } + return client; +} + +export async function createSourceUploadUrl({ key, contentType, expiresIn = 300 }) { + const client = requireClient(); const command = new PutObjectCommand({ Bucket: r2Bucket, Key: key, @@ -36,18 +45,53 @@ export async function createSourceUploadUrl({ key, contentType, expiresIn = 300 return getSignedUrl(client, command, { expiresIn }); } -export async function readSourceObject(key, maxBytes) { +export async function createSourceReadUrl({ key, expiresIn = 3600, downloadName = '' }) { if (!isSourceKey(key)) { const error = new Error('Invalid source key'); error.statusCode = 400; throw error; } - const client = getR2Client(); - if (!client) { - const error = new Error('R2 storage is not configured'); - error.statusCode = 503; + const client = requireClient(); + const command = new GetObjectCommand({ + Bucket: r2Bucket, + Key: key, + ...(downloadName ? { ResponseContentDisposition: `attachment; filename="${String(downloadName).replace(/["\\\r\n]/g, '_')}"` } : {}), + }); + return getSignedUrl(client, command, { expiresIn }); +} + +export async function headSourceObject(key) { + if (!isSourceKey(key)) { + const error = new Error('Invalid source key'); + error.statusCode = 400; + throw error; + } + const client = requireClient(); + const object = await client.send(new HeadObjectCommand({ Bucket: r2Bucket, Key: key })); + return { + size: Number(object.ContentLength || 0), + contentType: String(object.ContentType || 'application/octet-stream').split(';')[0].trim().toLowerCase(), + etag: String(object.ETag || '').replace(/^"|"$/g, ''), + }; +} + +export async function deleteSourceObject(key) { + if (!isSourceKey(key)) { + const error = new Error('Invalid source key'); + error.statusCode = 400; + throw error; + } + const client = requireClient(); + await client.send(new DeleteObjectCommand({ Bucket: r2Bucket, Key: key })); +} + +export async function readSourceObject(key, maxBytes) { + if (!isSourceKey(key)) { + const error = new Error('Invalid source key'); + error.statusCode = 400; throw error; } + const client = requireClient(); const object = await client.send(new GetObjectCommand({ Bucket: r2Bucket, Key: key })); const length = Number(object.ContentLength || 0); if (maxBytes && length > maxBytes) { diff --git a/apps/studio/api/history.js b/apps/studio/api/history.js index 50572778..a3b0a23a 100644 --- a/apps/studio/api/history.js +++ b/apps/studio/api/history.js @@ -12,6 +12,12 @@ function clampOffset(value) { return Math.max(parsed, 0); } +function cleanAfter(value) { + if (typeof value !== 'string' || !value) return ''; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString(); +} + export default async function handler(req, res) { if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); @@ -25,6 +31,8 @@ export default async function handler(req, res) { const model = typeof req.query?.model === 'string' ? req.query.model : ''; const search = typeof req.query?.search === 'string' ? req.query.search.trim().slice(0, 200) : ''; const sort = req.query?.sort === 'oldest' ? 'oldest' : 'newest'; + const after = cleanAfter(req.query?.after); + const favoritesOnly = req.query?.favorite === 'true'; const params = new URLSearchParams(); params.set('select', 'id,status,kind,mode,model,prompt,negative_prompt,r2_key,media_url,thumbnail_r2_key,thumbnail_url,mime_type,resolution,width,height,thumbnail_width,thumbnail_height,duration_ms,seed,workflow_id,provider,error_message,metadata,is_favorite,created_at,started_at,completed_at'); @@ -35,6 +43,8 @@ export default async function handler(req, res) { if (kind === 'image' || kind === 'video') params.set('kind', `eq.${kind}`); if (model) params.set('model', `eq.${model}`); if (search) params.set('prompt', `ilike.*${search.replace(/[*,]/g, ' ')}*`); + if (after) params.set('created_at', `gte.${after}`); + if (favoritesOnly) params.set('is_favorite', 'eq.true'); const [rows, modelRows] = await Promise.all([ supabaseRequest(`studio_generations?${params.toString()}`, { method: 'GET' }), diff --git a/apps/studio/api/uploads.js b/apps/studio/api/uploads.js index 1dd8b084..ac74395d 100644 --- a/apps/studio/api/uploads.js +++ b/apps/studio/api/uploads.js @@ -1,8 +1,17 @@ import { randomUUID } from 'node:crypto'; -import { createSourceUploadUrl } from './_r2.js'; +import { createSourceReadUrl, createSourceUploadUrl, deleteSourceObject, headSourceObject, isLibraryUploadKey } from './_r2.js'; +import { supabaseRequest } from './_supabase.js'; + +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; +const SUPPORTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); function safeFilename(value) { - return String(value || 'input.png').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120); + return String(value || 'input.png').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'input.png'; +} + +function cleanDisplayName(value, fallback = 'Untitled upload') { + const clean = String(value || fallback).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim(); + return (clean || fallback).slice(0, 240); } function extensionFor(contentType, filename) { @@ -13,37 +22,187 @@ function extensionFor(contentType, filename) { return ['png', 'jpg', 'jpeg', 'webp'].includes(ext) ? ext : 'png'; } -export default async function handler(req, res) { - if (req.method !== 'POST') { - res.setHeader('Allow', 'POST'); - return res.status(405).json({ error: 'Method not allowed' }); +function clampLimit(value) { + const parsed = Number.parseInt(String(value || '60'), 10); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 100) : 60; +} + +function clampOffset(value) { + const parsed = Number.parseInt(String(value || '0'), 10); + return Number.isFinite(parsed) ? Math.max(parsed, 0) : 0; +} + +function positiveInteger(value) { + const parsed = Number.parseInt(String(value || ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function isUuid(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || '')); +} + +async function presentAsset(row) { + const [url, downloadUrl] = await Promise.all([ + createSourceReadUrl({ key: row.r2_key, expiresIn: 3600 }), + createSourceReadUrl({ key: row.r2_key, expiresIn: 3600, downloadName: row.filename || row.display_name || 'upload' }), + ]); + return { + id: row.id, + key: row.r2_key, + filename: row.filename, + name: row.display_name, + mimeType: row.mime_type, + size: Number(row.size_bytes || 0), + width: row.width, + height: row.height, + favorite: Boolean(row.is_favorite), + metadata: row.metadata || {}, + createdAt: row.created_at, + updatedAt: row.updated_at, + url, + downloadUrl, + }; +} + +async function listUploads(req, res) { + const limit = clampLimit(req.query?.limit); + const offset = clampOffset(req.query?.offset); + const sort = req.query?.sort === 'oldest' ? 'oldest' : 'newest'; + const favoriteOnly = req.query?.favorite === 'true'; + const search = typeof req.query?.search === 'string' ? req.query.search.trim().slice(0, 200) : ''; + + const params = new URLSearchParams(); + params.set('select', 'id,r2_key,filename,display_name,mime_type,size_bytes,width,height,is_favorite,metadata,created_at,updated_at'); + params.set('order', sort === 'oldest' ? 'created_at.asc,id.asc' : 'created_at.desc,id.desc'); + params.set('limit', String(limit + 1)); + params.set('offset', String(offset)); + if (favoriteOnly) params.set('is_favorite', 'eq.true'); + if (search) params.set('display_name', `ilike.*${search.replace(/[*,]/g, ' ')}*`); + + const rows = await supabaseRequest(`studio_uploads?${params.toString()}`, { method: 'GET' }); + const allRows = Array.isArray(rows) ? rows : []; + const hasMore = allRows.length > limit; + const visibleRows = hasMore ? allRows.slice(0, limit) : allRows; + const items = await Promise.all(visibleRows.map(presentAsset)); + return res.status(200).json({ + items, + page: { limit, offset, nextOffset: hasMore ? offset + items.length : null, hasMore }, + }); +} + +async function createUploadTicket(body, res) { + const contentType = String(body.contentType || '').split(';')[0].trim().toLowerCase(); + const filename = safeFilename(body.filename); + const size = Number(body.size || 0); + if (!SUPPORTED_TYPES.has(contentType)) { + return res.status(415).json({ error: 'Only PNG, JPEG, and WebP source uploads are supported' }); + } + if (!Number.isFinite(size) || size <= 0 || size > MAX_UPLOAD_BYTES) { + return res.status(413).json({ error: 'Source upload must be between 1 byte and 25 MB' }); + } + + const now = new Date(); + const prefix = body.purpose === 'library-upload' ? 'uploads' : 'sources'; + const key = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extensionFor(contentType, filename)}`; + const uploadUrl = await createSourceUploadUrl({ key, contentType, expiresIn: 300 }); + return res.status(201).json({ + key, + uploadUrl, + method: 'PUT', + contentType, + expiresIn: 300, + maxBytes: MAX_UPLOAD_BYTES, + }); +} + +async function completeLibraryUpload(body, res) { + const key = String(body.key || ''); + if (!isLibraryUploadKey(key)) return res.status(400).json({ error: 'Invalid library upload key' }); + + const filename = safeFilename(body.filename); + const displayName = cleanDisplayName(body.displayName, String(body.filename || 'Untitled upload').replace(/\.[^.]+$/, '')); + const expectedType = String(body.contentType || '').split(';')[0].trim().toLowerCase(); + const expectedSize = Number(body.size || 0); + if (!SUPPORTED_TYPES.has(expectedType)) return res.status(415).json({ error: 'Unsupported library upload type' }); + if (!Number.isFinite(expectedSize) || expectedSize <= 0 || expectedSize > MAX_UPLOAD_BYTES) return res.status(413).json({ error: 'Invalid library upload size' }); + + const existingParams = new URLSearchParams({ select: 'id,r2_key,filename,display_name,mime_type,size_bytes,width,height,is_favorite,metadata,created_at,updated_at', r2_key: `eq.${key}`, limit: '1' }); + const existingRows = await supabaseRequest(`studio_uploads?${existingParams.toString()}`, { method: 'GET' }); + if (Array.isArray(existingRows) && existingRows[0]) return res.status(200).json({ item: await presentAsset(existingRows[0]) }); + + const object = await headSourceObject(key); + if (object.size <= 0 || object.size > MAX_UPLOAD_BYTES || object.size !== expectedSize) { + return res.status(409).json({ error: 'Uploaded object size does not match the upload ticket' }); } + if (object.contentType !== expectedType) { + return res.status(409).json({ error: 'Uploaded object type does not match the upload ticket' }); + } + + const rows = await supabaseRequest('studio_uploads?select=*', { + method: 'POST', + headers: { Prefer: 'return=representation' }, + body: JSON.stringify({ + r2_key: key, + filename, + display_name: displayName, + mime_type: expectedType, + size_bytes: expectedSize, + width: positiveInteger(body.width), + height: positiveInteger(body.height), + metadata: { etag: object.etag || null, source: 'gallery-upload' }, + }), + }); + const row = Array.isArray(rows) ? rows[0] : rows; + return res.status(201).json({ item: await presentAsset(row) }); +} + +async function updateUpload(req, res) { + const body = typeof req.body === 'object' && req.body ? req.body : {}; + const id = String(body.id || req.query?.id || ''); + if (!isUuid(id)) return res.status(400).json({ error: 'Valid upload id is required' }); + + const patch = { updated_at: new Date().toISOString() }; + if (Object.hasOwn(body, 'displayName')) patch.display_name = cleanDisplayName(body.displayName); + if (Object.hasOwn(body, 'favorite')) patch.is_favorite = Boolean(body.favorite); + if (Object.keys(patch).length === 1) return res.status(400).json({ error: 'No upload changes were supplied' }); + const rows = await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}&select=*`, { + method: 'PATCH', + headers: { Prefer: 'return=representation' }, + body: JSON.stringify(patch), + }); + const row = Array.isArray(rows) ? rows[0] : rows; + if (!row) return res.status(404).json({ error: 'Upload not found' }); + return res.status(200).json({ item: await presentAsset(row) }); +} + +async function deleteUpload(req, res) { + const id = String(req.query?.id || req.body?.id || ''); + if (!isUuid(id)) return res.status(400).json({ error: 'Valid upload id is required' }); + + const rows = await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}&select=id,r2_key&limit=1`, { method: 'GET' }); + const row = Array.isArray(rows) ? rows[0] : null; + if (!row) return res.status(404).json({ error: 'Upload not found' }); + + await deleteSourceObject(row.r2_key); + await supabaseRequest(`studio_uploads?id=eq.${encodeURIComponent(id)}`, { method: 'DELETE' }); + return res.status(200).json({ deleted: true, id }); +} + +export default async function handler(req, res) { try { - const body = typeof req.body === 'object' && req.body ? req.body : {}; - const contentType = String(body.contentType || '').split(';')[0].trim().toLowerCase(); - const filename = safeFilename(body.filename); - const size = Number(body.size || 0); - if (!['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) { - return res.status(415).json({ error: 'Only PNG, JPEG, and WebP source uploads are supported' }); - } - if (!Number.isFinite(size) || size <= 0 || size > 25 * 1024 * 1024) { - return res.status(413).json({ error: 'Source upload must be between 1 byte and 25 MB' }); + if (req.method === 'GET') return await listUploads(req, res); + if (req.method === 'POST') { + const body = typeof req.body === 'object' && req.body ? req.body : {}; + if (body.phase === 'complete') return await completeLibraryUpload(body, res); + return await createUploadTicket(body, res); } - - const now = new Date(); - const key = `sources/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extensionFor(contentType, filename)}`; - const uploadUrl = await createSourceUploadUrl({ key, contentType, expiresIn: 300 }); - return res.status(201).json({ - key, - uploadUrl, - method: 'PUT', - contentType, - expiresIn: 300, - maxBytes: 25 * 1024 * 1024, - }); + if (req.method === 'PATCH') return await updateUpload(req, res); + if (req.method === 'DELETE') return await deleteUpload(req, res); + res.setHeader('Allow', 'GET, POST, PATCH, DELETE'); + return res.status(405).json({ error: 'Method not allowed' }); } catch (error) { - console.error('Source upload ticket failed', error); - return res.status(error?.statusCode || 500).json({ error: error?.message || 'Could not create source upload ticket' }); + console.error('Uploads library request failed', error); + return res.status(error?.statusCode || 500).json({ error: error?.message || 'Uploads library request failed' }); } } diff --git a/apps/studio/index.html b/apps/studio/index.html index 253caed0..cb480734 100644 --- a/apps/studio/index.html +++ b/apps/studio/index.html @@ -5,6 +5,7 @@ + diff --git a/apps/studio/package.json b/apps/studio/package.json index f12525d3..3d013170 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -5,11 +5,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "node scripts/check-workflow-contract.mjs && node scripts/check-worker-registry-contract.mjs && node scripts/check-resolution-contract.mjs && node scripts/check-generate-action-contract.mjs && node scripts/check-audio-control-contract.mjs && node scripts/check-generation-lifecycle-contract.mjs && node scripts/check-accessibility-polish-contract.mjs && vite build", + "build": "node scripts/check-workflow-contract.mjs && node scripts/check-worker-registry-contract.mjs && node scripts/check-resolution-contract.mjs && node scripts/check-generate-action-contract.mjs && node scripts/check-audio-control-contract.mjs && node scripts/check-generation-lifecycle-contract.mjs && node scripts/check-accessibility-polish-contract.mjs && node scripts/check-uploads-library-contract.mjs && vite build", "test:poster": "node scripts/check-video-poster-contract.mjs", "preview": "vite preview", "visual:responsive": "node scripts/capture-responsive-preview.mjs", - "visual:preview": "node scripts/capture-ui-preview.mjs && node scripts/capture-video-output-preview.mjs && node scripts/capture-audio-state-preview.mjs && node scripts/capture-gallery-preview.mjs && node scripts/capture-jobs-preview.mjs && node scripts/capture-gallery-thumbnail-preview.mjs && npm run visual:responsive", + "visual:preview": "node scripts/capture-ui-preview.mjs && node scripts/capture-video-output-preview.mjs && node scripts/capture-audio-state-preview.mjs && node scripts/capture-gallery-preview.mjs && node scripts/capture-uploads-preview.mjs && node scripts/capture-jobs-preview.mjs && node scripts/capture-gallery-thumbnail-preview.mjs && npm run visual:responsive", "visual:regression": "node scripts/check-visual-regression.mjs" }, "dependencies": { diff --git a/apps/studio/scripts/capture-gallery-preview.mjs b/apps/studio/scripts/capture-gallery-preview.mjs index b5d73809..9134b826 100644 --- a/apps/studio/scripts/capture-gallery-preview.mjs +++ b/apps/studio/scripts/capture-gallery-preview.mjs @@ -80,6 +80,13 @@ async function assertTouchTargets(locator, minimum = 44) { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, colorScheme: 'dark' }); + await page.addInitScript(() => { + const nativeMatchMedia = window.matchMedia.bind(window); + window.matchMedia = (query) => { + if (query !== '(hover: hover) and (pointer: fine)') return nativeMatchMedia(query); + return { matches: true, media: query, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; } }; + }; + }); page.on('pageerror', (error) => diagnostics.pageErrors.push({ label: 'desktop', text: error?.stack || error?.message || String(error) })); await mockHistory(page); @@ -120,10 +127,10 @@ try { await searchInput.fill('shoreline'); await page.waitForTimeout(250); if (!page.url().includes('#/gallery')) throw new Error('Gallery search unexpectedly changed navigation'); - await page.getByLabel('Sort').selectOption('oldest'); + await page.locator('.gallery-sort select').selectOption('oldest'); await page.waitForTimeout(250); await searchInput.fill(''); - await page.getByLabel('Sort').selectOption('newest'); + await page.locator('.gallery-sort select').selectOption('newest'); const compactDensity = page.getByRole('button', { name: 'Compact', exact: true }); const comfortableDensity = page.getByRole('button', { name: 'Comfortable', exact: true }); @@ -233,7 +240,7 @@ try { await thirdSelectButton.press('Enter'); if (await thirdSelectButton.getAttribute('aria-pressed') !== 'true') throw new Error('Enter did not select the third Gallery card'); if (!(await manager.locator('strong').innerText()).includes('2 selected')) throw new Error('Gallery manager did not track two keyboard-selected items'); - for (const label of ['Favorite', 'Download ZIP', 'Delete']) { + for (const label of ['Favorite', 'Download', 'Delete']) { const button = manager.getByRole('button', { name: label, exact: true }); if (await button.isDisabled()) throw new Error(`${label} bulk action stayed disabled after selection`); } @@ -288,7 +295,7 @@ try { await mobile.screenshot({ path: path.join(outputDir, '13-gallery-mobile.png'), fullPage: true, animations: 'disabled' }); diagnostics.screenshots.push('13-gallery-mobile.png'); - await mobile.getByRole('button', { name: 'Comfortable', exact: true }).click(); + await mobile.getByRole('button', { name: 'Gallery layout: compact', exact: true }).click(); const mobileComfortableGrid = mobile.locator('.gallery-grid'); if (await mobileComfortableGrid.getAttribute('data-density') !== 'comfortable') throw new Error('Mobile Comfortable density did not activate'); const mobileComfortableFirst = await mobileCards.first().boundingBox(); @@ -298,7 +305,7 @@ try { } await mobile.screenshot({ path: path.join(outputDir, '13c-gallery-mobile-comfortable.png'), fullPage: true, animations: 'disabled' }); diagnostics.screenshots.push('13c-gallery-mobile-comfortable.png'); - await mobile.getByRole('button', { name: 'Compact', exact: true }).click(); + await mobile.getByRole('button', { name: 'Gallery layout: comfortable', exact: true }).click(); await mobileOverlay.getByRole('button', { name: 'More actions', exact: true }).click(); const mobileMore = mobileCards.first().locator('.media-actions-popover'); diff --git a/apps/studio/scripts/capture-gallery-thumbnail-preview.mjs b/apps/studio/scripts/capture-gallery-thumbnail-preview.mjs index 782738d7..8f7c9324 100644 --- a/apps/studio/scripts/capture-gallery-thumbnail-preview.mjs +++ b/apps/studio/scripts/capture-gallery-thumbnail-preview.mjs @@ -12,13 +12,13 @@ const now = new Date().toISOString(); const rows = [ { id: '99999999-9999-4999-8999-999999999991', kind: 'image', mode: 'edit', - prompt: 'Portrait reference preserving the full vertical composition', model: 'FLUX.2 Klein 9B · DarkBeast V2 BFS', + prompt: 'Portrait reference filling the square gallery card', model: 'FLUX.2 Klein 9B · DarkBeast V2 BFS', resolution: '≈ 1088 × 1440 · 1.57 MP', width: 1088, height: 1440, seed: 42, media_url: '/mock/gallery-portrait.svg', thumbnail_url: null, metadata: { execution: {} }, created_at: now, }, { id: '99999999-9999-4999-8999-999999999992', kind: 'image', mode: 'edit', - prompt: 'Wide reference preserving the full landscape composition', model: 'FLUX.2 Klein 9B · DarkBeast V2 BFS', + prompt: 'Wide reference filling the square gallery card', model: 'FLUX.2 Klein 9B · DarkBeast V2 BFS', resolution: '2048 × 1152 · Manual', width: 2048, height: 1152, seed: 73, media_url: '/mock/gallery-landscape.svg', thumbnail_url: null, metadata: { execution: {} }, created_at: now, }, @@ -35,8 +35,8 @@ try { await page.route('**/api/history?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: rows, page: { offset: 0, limit: 24, nextOffset: null, hasMore: false }, facets: { models: [rows[0].model] } }) }); }); - await page.route('**/mock/gallery-portrait.svg', async (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg('FULL PORTRAIT', 1088, 1440) })); - await page.route('**/mock/gallery-landscape.svg', async (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg('FULL LANDSCAPE', 2048, 1152) })); + await page.route('**/mock/gallery-portrait.svg', async (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg('PORTRAIT COVER', 1088, 1440) })); + await page.route('**/mock/gallery-landscape.svg', async (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg('LANDSCAPE COVER', 2048, 1152) })); await page.goto(galleryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await page.getByRole('heading', { name: 'Gallery', exact: true }).waitFor({ state: 'visible', timeout: 20_000 }); @@ -50,7 +50,7 @@ try { const computed = getComputedStyle(element); return { backgroundSize: computed.backgroundSize, backgroundRepeat: computed.backgroundRepeat, backgroundPosition: computed.backgroundPosition, backgroundImage: computed.backgroundImage }; }); - if (style.backgroundSize !== 'contain' || style.backgroundRepeat !== 'no-repeat') throw new Error(`${label} thumbnail is still cropped: ${JSON.stringify(style)}`); + if (style.backgroundSize !== 'cover' || style.backgroundRepeat !== 'no-repeat') throw new Error(`${label} thumbnail does not fill its gallery card: ${JSON.stringify(style)}`); if (!style.backgroundImage || style.backgroundImage === 'none') throw new Error(`${label} thumbnail image did not render`); } diff --git a/apps/studio/scripts/capture-responsive-preview.mjs b/apps/studio/scripts/capture-responsive-preview.mjs index 3e7c84bc..14197017 100644 --- a/apps/studio/scripts/capture-responsive-preview.mjs +++ b/apps/studio/scripts/capture-responsive-preview.mjs @@ -176,15 +176,22 @@ try { await page.getByRole('heading', { name: 'Gallery', exact: true }).waitFor({ state: 'visible', timeout: 20_000 }); const cards = page.locator('.gallery-grid .gallery-card'); await cards.nth(5).waitFor({ state: 'visible', timeout: 10_000 }); - const search = page.getByRole('searchbox', { name: 'Search prompts' }); - const modelSelect = page.locator('.gallery-model-filter select'); + const search = width <= 760 ? page.getByRole('button', { name: 'Search Gallery', exact: true }) : page.locator('.gallery-search-desktop input'); + const modelSelect = width <= 760 ? page.locator('.gallery-mobile-filter-panel .gallery-model-filter select') : page.locator('.gallery-model-filter select').first(); await assertNoHorizontalOverflow(page, 'Gallery compact', width); await assertContained(page.locator('main.workspace'), 'Gallery workspace', width); await assertContained(search, 'Gallery search', width); await assertContained(page.locator('.gallery-grid'), 'Gallery grid', width); await assertNoOverlap(search, page.locator('.gallery-sort'), 'Gallery search / sort', width); if (width <= 390) { - await assertMinimumWidth(search, 'Gallery search', width, 140); + await search.click(); + const mobileSearch = page.locator('.gallery-search-mobile input'); + await mobileSearch.waitFor({ state: 'visible' }); + await assertMinimumWidth(mobileSearch, 'Gallery search', width, 140); + await page.getByRole('button', { name: /^Filter/ }).click(); + const mobileFilters = page.locator('.gallery-mobile-filter-panel'); + await mobileFilters.waitFor({ state: 'visible' }); + await assertContained(mobileFilters, 'Gallery mobile filters', width); await assertMinimumWidth(modelSelect, 'Gallery model filter', width, 90); } const firstCard = await cards.first().boundingBox(); diff --git a/apps/studio/scripts/capture-uploads-preview.mjs b/apps/studio/scripts/capture-uploads-preview.mjs new file mode 100644 index 00000000..7be34f72 --- /dev/null +++ b/apps/studio/scripts/capture-uploads-preview.mjs @@ -0,0 +1,133 @@ +import { chromium } from 'playwright'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const baseUrl = process.env.UI_PREVIEW_URL || 'http://127.0.0.1:4173/#/create'; +const galleryUrl = /#\//.test(baseUrl) ? baseUrl.replace(/#\/.*$/, '#/gallery') : `${baseUrl.replace(/\/$/, '')}/#/gallery`; +const outputDir = path.resolve(process.env.UI_PREVIEW_DIR || 'visual-preview'); +await mkdir(outputDir, { recursive: true }); + +function poster(label, width = 800, height = 1000, accent = '#8269ff') { + const markup = `${label}`; + return `data:image/svg+xml,${encodeURIComponent(markup)}`; +} + +const initialAssets = [ + { id: '71111111-1111-4111-8111-111111111111', key: 'uploads/2026/08/71111111-1111-4111-8111-111111111111.jpg', filename: 'portrait-reference.jpg', name: 'portrait-reference', mimeType: 'image/jpeg', size: 1832441, width: 800, height: 1000, favorite: true, metadata: {}, createdAt: '2026-08-25T03:00:00Z', updatedAt: '2026-08-25T03:00:00Z', url: poster('PORTRAIT', 800, 1000, '#ff6f91'), downloadUrl: poster('PORTRAIT', 800, 1000, '#ff6f91') }, + { id: '72222222-2222-4222-8222-222222222222', key: 'uploads/2026/08/72222222-2222-4222-8222-222222222222.webp', filename: 'shoreline.webp', name: 'shoreline', mimeType: 'image/webp', size: 944128, width: 1200, height: 800, favorite: false, metadata: {}, createdAt: '2026-08-24T20:00:00Z', updatedAt: '2026-08-24T20:00:00Z', url: poster('SHORELINE', 1200, 800, '#4ab9ff'), downloadUrl: poster('SHORELINE', 1200, 800, '#4ab9ff') }, +]; +let assets = initialAssets.map((asset) => ({ ...asset })); +const diagnostics = { galleryUrl, screenshots: [], pageErrors: [], uploadFlowCompleted: false }; + +async function installMocks(page) { + await page.route('**/api/history?**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { offset: 0, limit: 24, nextOffset: null, hasMore: false }, facets: { models: [] } }) })); + await page.route('**/api/collections**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ collections: [] }) })); + await page.route('https://uploads-preview.invalid/**', (route) => route.fulfill({ status: 200, body: '' })); + await page.route('**/api/uploads?**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.continue(); + let filtered = [...assets]; + const query = String(url.searchParams.get('search') || '').toLowerCase(); + if (query) filtered = filtered.filter((asset) => asset.name.toLowerCase().includes(query)); + if (url.searchParams.get('favorite') === 'true') filtered = filtered.filter((asset) => asset.favorite); + if (url.searchParams.get('sort') === 'oldest') filtered.reverse(); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: filtered, page: { limit: 100, offset: 0, nextOffset: null, hasMore: false } }) }); + }); + await page.route('**/api/uploads', async (route) => { + const request = route.request(); + const method = request.method(); + if (method === 'GET') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: assets, page: { limit: 100, offset: 0, nextOffset: null, hasMore: false } }) }); + const body = request.postDataJSON?.() || {}; + if (method === 'POST' && body.phase === 'complete') { + const created = { id: '73333333-3333-4333-8333-333333333333', key: body.key, filename: body.filename, name: body.displayName || 'new-upload', mimeType: body.contentType, size: body.size, width: body.width || 1, height: body.height || 1, favorite: false, metadata: {}, createdAt: '2026-08-25T03:10:00Z', updatedAt: '2026-08-25T03:10:00Z', url: poster('NEW UPLOAD', 900, 900, '#73dfa5'), downloadUrl: poster('NEW UPLOAD', 900, 900, '#73dfa5') }; + assets = [created, ...assets.filter((asset) => asset.id !== created.id)]; + diagnostics.uploadFlowCompleted = true; + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ item: created }) }); + } + if (method === 'POST') { + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ key: 'uploads/2026/08/73333333-3333-4333-8333-333333333333.png', uploadUrl: 'https://uploads-preview.invalid/73333333.png', method: 'PUT', contentType: body.contentType || 'image/png', expiresIn: 300, maxBytes: 26214400 }) }); + } + if (method === 'PATCH') { + const current = assets.find((asset) => asset.id === body.id); + if (!current) return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'Upload not found' }) }); + const next = { ...current, ...(Object.hasOwn(body, 'displayName') ? { name: body.displayName } : {}), ...(Object.hasOwn(body, 'favorite') ? { favorite: Boolean(body.favorite) } : {}) }; + assets = assets.map((asset) => asset.id === next.id ? next : asset); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ item: next }) }); + } + if (method === 'DELETE') { + const id = new URL(request.url()).searchParams.get('id'); + assets = assets.filter((asset) => asset.id !== id); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ deleted: true, id }) }); + } + return route.fulfill({ status: 405, contentType: 'application/json', body: JSON.stringify({ error: 'Method not allowed' }) }); + }); +} + +const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', 'base64'); +const browser = await chromium.launch({ headless: true }); +try { + const desktop = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, colorScheme: 'dark' }); + desktop.on('pageerror', (error) => diagnostics.pageErrors.push({ surface: 'desktop', text: error?.stack || error?.message || String(error) })); + await installMocks(desktop); + await desktop.goto(galleryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await desktop.getByRole('heading', { name: 'Gallery', exact: true }).waitFor({ state: 'visible', timeout: 20_000 }); + const uploadsTab = desktop.getByRole('tab', { name: 'Uploads', exact: true }); + if (await uploadsTab.isDisabled()) throw new Error('Uploads tab is still disabled'); + await uploadsTab.click(); + if (await uploadsTab.getAttribute('aria-selected') !== 'true') throw new Error('Uploads tab did not become active'); + const uploadTile = desktop.getByRole('button', { name: 'Upload', exact: true }); + await uploadTile.waitFor({ state: 'visible' }); + const cards = desktop.locator('.upload-asset-card'); + await cards.nth(1).waitFor({ state: 'visible' }); + if (await cards.count() !== 2) throw new Error(`Expected 2 seeded upload cards, found ${await cards.count()}`); + await desktop.screenshot({ path: path.join(outputDir, '15-gallery-uploads-desktop.png'), fullPage: true, animations: 'disabled' }); + diagnostics.screenshots.push('15-gallery-uploads-desktop.png'); + + await cards.first().locator('.upload-asset-primary').click(); + const detail = desktop.getByRole('dialog', { name: /Preview portrait-reference/ }); + await detail.waitFor({ state: 'visible' }); + await detail.getByRole('button', { name: 'Set as Reference', exact: true }).waitFor({ state: 'visible' }); + await detail.getByRole('button', { name: 'Generate Video', exact: true }).waitFor({ state: 'visible' }); + await desktop.screenshot({ path: path.join(outputDir, '15b-gallery-upload-detail.png'), fullPage: true, animations: 'disabled' }); + diagnostics.screenshots.push('15b-gallery-upload-detail.png'); + await detail.getByRole('button', { name: 'Close upload preview', exact: true }).click(); + + await desktop.locator('input[type="file"]').setInputFiles({ name: 'new-reference.png', mimeType: 'image/png', buffer: tinyPng }); + await desktop.getByText('new-reference', { exact: true }).waitFor({ state: 'visible', timeout: 10_000 }); + if (!diagnostics.uploadFlowCompleted) throw new Error('Upload ticket/PUT/finalize flow did not complete'); + if (await cards.count() !== 3) throw new Error(`Uploaded asset did not join the library grid; found ${await cards.count()} cards`); + + const mobile = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, colorScheme: 'dark', isMobile: true, hasTouch: true }); + mobile.on('pageerror', (error) => diagnostics.pageErrors.push({ surface: 'mobile', text: error?.stack || error?.message || String(error) })); + await installMocks(mobile); + await mobile.goto(galleryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await mobile.getByRole('heading', { name: 'Gallery', exact: true }).waitFor({ state: 'visible', timeout: 20_000 }); + await mobile.getByRole('tab', { name: 'Uploads', exact: true }).click(); + const mobileCards = mobile.locator('.upload-asset-card'); + await mobileCards.nth(1).waitFor({ state: 'visible' }); + const addBox = await mobile.getByRole('button', { name: 'Upload', exact: true }).boundingBox(); + const firstBox = await mobileCards.first().boundingBox(); + if (!addBox || !firstBox || Math.abs(addBox.width - firstBox.width) > 4 || Math.abs(addBox.x - firstBox.x) < addBox.width * .7) { + throw new Error(`Mobile Uploads is not a stable two-column asset grid: ${JSON.stringify({ addBox, firstBox })}`); + } + await mobile.screenshot({ path: path.join(outputDir, '16-gallery-uploads-mobile.png'), fullPage: true, animations: 'disabled' }); + diagnostics.screenshots.push('16-gallery-uploads-mobile.png'); + + await mobile.getByRole('button', { name: 'Manage', exact: true }).click(); + const manager = mobile.getByRole('toolbar', { name: 'Selected upload actions', exact: true }); + await manager.waitFor({ state: 'visible' }); + const selectButton = mobileCards.first().locator('.upload-asset-primary'); + await selectButton.click(); + if (await selectButton.getAttribute('aria-pressed') !== 'true') throw new Error('Mobile Uploads selection state did not activate'); + if (!(await manager.locator('strong').innerText()).includes('1 selected')) throw new Error('Mobile Uploads manager did not track selected asset'); + const managerBox = await manager.boundingBox(); + if (!managerBox || managerBox.x < 0 || managerBox.x + managerBox.width > 390) throw new Error(`Mobile Uploads manager overflows viewport: ${JSON.stringify(managerBox)}`); + await mobile.screenshot({ path: path.join(outputDir, '16b-gallery-uploads-mobile-manager.png'), fullPage: true, animations: 'disabled' }); + diagnostics.screenshots.push('16b-gallery-uploads-mobile-manager.png'); + + if (diagnostics.pageErrors.length) throw new Error(`Uploads page errors: ${diagnostics.pageErrors.map((entry) => `${entry.surface}: ${entry.text}`).join(' | ')}`); +} finally { + await writeFile(path.join(outputDir, 'uploads-diagnostics.json'), JSON.stringify(diagnostics, null, 2)); + await browser.close(); +} diff --git a/apps/studio/scripts/check-accessibility-polish-contract.mjs b/apps/studio/scripts/check-accessibility-polish-contract.mjs index 808aa3c1..5adb59c7 100644 --- a/apps/studio/scripts/check-accessibility-polish-contract.mjs +++ b/apps/studio/scripts/check-accessibility-polish-contract.mjs @@ -19,7 +19,7 @@ requireSource(tokens, '--saga-color-text-subtle: #8993a3;', 'stronger subtle tex requireSource(tokens, '--saga-focus-shadow:', 'supplemental focus halo'); requireSource(polish, '.app-shell button:focus-visible', 'consistent keyboard focus rule'); requireSource(polish, 'box-shadow: var(--saga-focus-shadow);', 'focus halo usage'); -requireSource(galleryView, 'aria-pressed={kind === value}', 'media filter state semantics'); +requireSource(galleryView, 'aria-label="Type"', 'media filter accessible naming'); requireSource(galleryView, 'aria-pressed={managing}', 'Manage state semantics'); requireSource(gallery, 'font-weight:700;', 'non-color selected-state emphasis'); requireSource(gallery, 'box-shadow:inset 0 -2px 0 var(--saga-color-accent-soft)', 'selected-state shape cue'); diff --git a/apps/studio/scripts/check-generation-lifecycle-contract.mjs b/apps/studio/scripts/check-generation-lifecycle-contract.mjs index d08510df..c86dbd8e 100644 --- a/apps/studio/scripts/check-generation-lifecycle-contract.mjs +++ b/apps/studio/scripts/check-generation-lifecycle-contract.mjs @@ -1,17 +1,27 @@ import { readFile } from 'node:fs/promises'; const root = new URL('../', import.meta.url); -const [client, controller, workspace, controls] = await Promise.all([ +const [client, controller, workspace, controls, app, recovery, libraryController, jobsView, galleryView, favoritesView] = await Promise.all([ readFile(new URL('src/generation-client.js', root), 'utf8'), readFile(new URL('src/hooks/useGenerationController.js', root), 'utf8'), readFile(new URL('src/features/create/CreateWorkspace.jsx', root), 'utf8'), readFile(new URL('src/features/create/VideoGenerationControls.jsx', root), 'utf8'), + readFile(new URL('src/app/App.jsx', root), 'utf8'), + readFile(new URL('server/job-recovery.js', root), 'utf8'), + readFile(new URL('src/hooks/useLibraryController.js', root), 'utf8'), + readFile(new URL('src/features/jobs/JobsView.jsx', root), 'utf8'), + readFile(new URL('src/features/library/GalleryView.jsx', root), 'utf8'), + readFile(new URL('src/features/library/FavoritesView.jsx', root), 'utf8'), ]); const requireText = (source, text, label) => { if (!source.includes(text)) throw new Error(`Missing lifecycle contract: ${label}`); }; +const forbidText = (source, text, label) => { + if (source.includes(text)) throw new Error(`Forbidden lifecycle contract: ${label}`); +}; + requireText(client, 'if (options.onJob) options.onJob(submitted.job);', 'submitted job callback'); requireText(client, "throw new DOMException('Generation cancelled', 'AbortError')", 'abortable generation polling'); requireText(controller, "runJobAction(activeJob.id, 'cancel')", 'Create cancellation uses real job action'); @@ -23,4 +33,20 @@ requireText(controls, '> View Job', 'View Job control'); requireText(controls, "'Cancelling…' : 'Cancel'", 'Cancel control'); requireText(controls, "cancelled: ['Generation cancelled'", 'explicit cancelled state'); +requireText(app, "window.setInterval(() => loadJobs({ silent: true, filter: jobsFilter }), 3000)", 'Jobs automatic polling'); +requireText(recovery, 'persistVideoJobResult(', 'video recovery persistence'); +requireText(recovery, 'result.posterBytes || null', 'recovered video poster persistence'); +requireText(recovery, "outcome: 'completed'", 'recovered video completion state'); +requireText(libraryController, 'const AUTO_REFRESH_MS = 5000;', 'library automatic refresh interval'); +requireText(libraryController, 'window.setInterval(() => refresh(), AUTO_REFRESH_MS)', 'library automatic polling'); +requireText(libraryController, "document.visibilityState === 'visible'", 'library refresh on visibility'); +requireText(libraryController, "window.addEventListener('focus', onFocus)", 'library refresh on focus'); +requireText(libraryController, 'preserveLoaded: !initial', 'Gallery automatic refresh preserves loaded pages'); + +for (const [source, label] of [[jobsView, 'Jobs'], [galleryView, 'Gallery'], [favoritesView, 'Favorites']]) { + forbidText(source, 'RefreshCcw', `${label} manual refresh icon`); + forbidText(source, 'onRefresh', `${label} manual refresh callback`); + forbidText(source, '> Refresh', `${label} manual refresh button`); +} + console.log('Generation lifecycle contract passed.'); diff --git a/apps/studio/scripts/check-uploads-library-contract.mjs b/apps/studio/scripts/check-uploads-library-contract.mjs new file mode 100644 index 00000000..d1ce3821 --- /dev/null +++ b/apps/studio/scripts/check-uploads-library-contract.mjs @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; + +const uploadsApi = await readFile(new URL('../api/uploads.js', import.meta.url), 'utf8'); +const r2 = await readFile(new URL('../api/_r2.js', import.meta.url), 'utf8'); +const gallery = await readFile(new URL('../src/features/library/GalleryView.jsx', import.meta.url), 'utf8'); +const uploadsView = await readFile(new URL('../src/features/library/UploadsView.jsx', import.meta.url), 'utf8'); +const app = await readFile(new URL('../src/app/App.jsx', import.meta.url), 'utf8'); +const migration = await readFile(new URL('../../../supabase/migrations/20260825062000_add_studio_upload_asset_library.sql', import.meta.url), 'utf8'); + +const checks = [ + [migration.includes('create table if not exists public.studio_uploads'), 'studio_uploads migration is missing'], + [migration.includes('is_favorite boolean not null default false'), 'upload favorites persistence is missing'], + [uploadsApi.includes("body.purpose === 'library-upload' ? 'uploads' : 'sources'"), 'library uploads are not separated from transient generation sources'], + [uploadsApi.includes("if (req.method === 'GET')"), 'Uploads listing endpoint is missing'], + [uploadsApi.includes("if (req.method === 'PATCH')"), 'Uploads update endpoint is missing'], + [uploadsApi.includes("if (req.method === 'DELETE')"), 'Uploads delete endpoint is missing'], + [uploadsApi.includes("body.phase === 'complete'"), 'Uploads finalize phase is missing'], + [uploadsApi.includes('headSourceObject(key)'), 'Uploads finalize does not verify the R2 object'], + [r2.includes('createSourceReadUrl'), 'R2 signed read URLs are missing'], + [r2.includes('deleteSourceObject'), 'R2 asset deletion is missing'], + [r2.includes('(?:sources|uploads)'), 'Generation source validation does not allow persisted uploads'], + [gallery.includes("import UploadsView from './UploadsView.jsx'"), 'Gallery does not mount the Uploads library'], + [gallery.includes("onClick={() => switchLibrary('uploads')}"), 'Uploads tab is not interactive'], + [!gallery.includes('Uploads library is not available yet'), 'Legacy disabled Uploads tab remains'], + [uploadsView.includes("purpose: 'library-upload'"), 'Uploads UI does not request persistent upload tickets'], + [uploadsView.includes("phase: 'complete'"), 'Uploads UI does not finalize persisted assets'], + [uploadsView.includes('Set as Reference'), 'Upload detail view is missing Set as Reference'], + [uploadsView.includes('Generate Video'), 'Upload detail view is missing Generate Video'], + [uploadsView.includes('Selected upload actions'), 'Upload batch manager is missing'], + [app.includes('onUseUploadReference={useUploadReference}'), 'Upload reuse is not wired into Create'], +]; + +const failures = checks.filter(([ok]) => !ok).map(([, message]) => message); +if (failures.length) { + console.error(`Uploads library contract failed:\n- ${failures.join('\n- ')}`); + process.exit(1); +} +console.log('Uploads library contract passed: durable R2 catalog, list/search/favorite/rename/delete, batch actions, preview, and Create reuse are wired.'); diff --git a/apps/studio/server/job-recovery.js b/apps/studio/server/job-recovery.js index 673c23ef..82c217e9 100644 --- a/apps/studio/server/job-recovery.js +++ b/apps/studio/server/job-recovery.js @@ -1,7 +1,7 @@ import { getWorkflow } from '../api/_workflows.js'; import { listGenerationJobs, transitionGenerationJob, updateGenerationWorkerAssignment } from '../api/_generation-jobs.js'; import { pollWorkflow } from '../api/_providers.js'; -import { persistImageJobResult } from '../api/_result-persistence.js'; +import { persistImageJobResult, persistVideoJobResult } from '../api/_result-persistence.js'; const STALE_WITHOUT_PROVIDER_MS = 2 * 60 * 1000; @@ -35,6 +35,21 @@ async function persistRecoveredWorkerState(job, worker) { } } +// Recovery uses the same persistence helpers as the foreground result poll so +// a completed provider job reaches the same durable Gallery state after reloads. +async function persistRecoveredResult(job, workflow, result) { + if (job.kind === 'video') { + return persistVideoJobResult( + job, + result.bytes, + result.contentType || workflow.outputMimeType, + result.posterBytes || null, + result.posterContentType || 'image/jpeg', + ); + } + return persistImageJobResult(job, result.bytes, result.contentType || workflow.outputMimeType); +} + async function recoverJob(job) { if (!job?.id || !['queued', 'running'].includes(job.status)) return { id: job?.id, outcome: 'ignored' }; @@ -60,9 +75,13 @@ async function recoverJob(job) { await persistRecoveredWorkerState(job, result.worker || { state: 'generating' }); return { id: job.id, outcome: 'running' }; } - if (job.kind !== 'image') return { id: job.id, outcome: 'running' }; - const completed = await persistImageJobResult(job, result.bytes, result.contentType || workflow.outputMimeType); - return { id: job.id, outcome: 'completed', mediaUrl: completed.media_url, thumbnailUrl: completed.thumbnail_url || null }; + const completed = await persistRecoveredResult(job, workflow, result); + return { + id: job.id, + outcome: 'completed', + mediaUrl: completed.media_url, + thumbnailUrl: completed.thumbnail_url || null, + }; } catch (error) { const statusCode = Number(error?.statusCode || 500); if (statusCode >= 400 && statusCode < 500 && statusCode !== 408 && statusCode !== 429) { diff --git a/apps/studio/src/api/studioApi.js b/apps/studio/src/api/studioApi.js index f618fb8a..c75316a7 100644 --- a/apps/studio/src/api/studioApi.js +++ b/apps/studio/src/api/studioApi.js @@ -4,12 +4,29 @@ async function jsonResponse(response, fallback) { return payload; } -export async function fetchGallery({ limit, offset, kind, model, search, sort }) { +function galleryDateAfter(date) { + if (!['today', 'week', 'month'].includes(date)) return ''; + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + if (date === 'week') { + const daysSinceMonday = (start.getDay() + 6) % 7; + start.setDate(start.getDate() - daysSinceMonday); + } else if (date === 'month') { + start.setDate(1); + } + return start.toISOString(); +} + +export async function fetchGallery({ limit, offset, kind, model, search, sort, date = 'any', favoritesOnly = false }) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); if (kind === 'image' || kind === 'video') params.set('kind', kind); if (model && model !== 'all') params.set('model', model); if (search?.trim()) params.set('search', search.trim()); if (sort === 'oldest') params.set('sort', 'oldest'); + const after = galleryDateAfter(date); + if (after) params.set('after', after); + if (favoritesOnly) params.set('favorite', 'true'); const response = await fetch(`/api/history?${params.toString()}`, { headers: { Accept: 'application/json' } }); return jsonResponse(response, `Gallery request failed (${response.status})`); } diff --git a/apps/studio/src/app/App.jsx b/apps/studio/src/app/App.jsx index 8061f431..76f0fd9f 100644 --- a/apps/studio/src/app/App.jsx +++ b/apps/studio/src/app/App.jsx @@ -130,7 +130,7 @@ export default function App() { const autoEditInfo = useMemo(() => autoReferenceSizing(references[0]), [references]); const library = useLibraryController({ section, toGalleryItem }); - const { favorites, setFavorites, favoriteItems, setFavoriteItems, galleryItems, setGalleryItems, galleryLoading, galleryAppending, galleryError, galleryKind, setGalleryKind, galleryModel, setGalleryModel, gallerySearch, setGallerySearch, gallerySort, setGallerySort, galleryModels, galleryPage, libraryLoading, libraryError, setLibraryError, collections, setCollections, selectedCollection, setSelectedCollection, collectionItems, setCollectionItems, loadGallery, loadFavorites, loadCollections, loadCollectionItems } = library; + const { favorites, setFavorites, favoriteItems, setFavoriteItems, galleryItems, setGalleryItems, galleryLoading, galleryAppending, galleryError, galleryKind, setGalleryKind, galleryModel, setGalleryModel, gallerySearch, setGallerySearch, gallerySort, setGallerySort, galleryDate, setGalleryDate, galleryFavoritesOnly, setGalleryFavoritesOnly, galleryModels, galleryPage, libraryLoading, libraryError, setLibraryError, collections, setCollections, selectedCollection, setSelectedCollection, collectionItems, setCollectionItems, loadGallery, loadFavorites, loadCollections, loadCollectionItems } = library; const { busy, jobStatus, workerStatus, activeJob, cancelBusy, generate, viewActiveJob, cancelActiveJob } = useGenerationController({ mode, isEdit, prompt, references, seed, steps, cfg, autoEditInfo, section, setItems, loadGallery, setError, setSection, setJobsFilter }); const mediaActions = useMediaActions({ section, setSection, setMode, setPrompt, setSeed, setSteps, setCfg, setWorkflowId, setModelId, @@ -196,7 +196,6 @@ export default function App() { return () => window.clearInterval(timer); }, [section, jobsFilter]); - const addReferences = async (files) => { const valid = []; for (const file of files) { @@ -221,6 +220,39 @@ export default function App() { } }; + const useUploadReference = async (asset, targetMode = 'Edit') => { + if (!asset?.url) throw new Error('This upload is missing a readable asset URL.'); + const response = await fetch(asset.url, { cache: 'no-store' }); + if (!response.ok) throw new Error(`Could not prepare ${asset.name || 'upload'} as a reference (${response.status}).`); + const blob = await response.blob(); + const contentType = asset.mimeType || blob.type || 'image/png'; + const filename = asset.filename || `${asset.name || 'upload'}.${contentType === 'image/jpeg' ? 'jpg' : contentType === 'image/webp' ? 'webp' : 'png'}`; + const file = new File([blob], filename, { type: contentType, lastModified: Date.now() }); + const dimensions = asset.width && asset.height ? { width: asset.width, height: asset.height } : await imageDimensions(file); + const reference = { + id: `upload-${asset.id}-${Date.now()}`, + file, + preview: URL.createObjectURL(file), + uploadId: asset.id, + ...dimensions, + }; + setReferences((current) => { + current.forEach((item) => item.preview && URL.revokeObjectURL(item.preview)); + return [reference]; + }); + if (targetMode === 'Video') { + setMode('Video'); + setWorkflowId('video-planned'); + setModelId('saga-video-auto'); + } else { + setMode('Edit'); + setWorkflowId('flux2-klein-image-edit'); + setModelId('flux2-klein-9b'); + } + setError(''); + setSection('Create'); + }; + const removeReference = (index) => { const target = references[index]; if (!target) return; @@ -236,10 +268,6 @@ export default function App() { } }; - - - - const openMedia = (item) => setSelectedMedia(item); const renderCard = (item, history = false, inCollection = false) => ( setMobileNav(true)} onOpenSettings={() => setSettingsOpen(true)} /> {section === 'Jobs' ? loadJobs({ filter: jobsFilter })} onJobAction={runJobAction} /> - : section === 'Gallery' ? loadGallery({ append: false })} onLoadMore={() => loadGallery({ append: true })} renderCard={renderCard} onBulkFavorite={bulkFavorite} onBulkAddToCollection={bulkAddToCollection} onBulkDownload={bulkDownload} onBulkDelete={bulkDelete} /> + : section === 'Gallery' ? { await loadCollectionItems(collection); setSection('Collections'); }} onOpenCollections={() => setSection('Collections')} onRefresh={() => loadGallery({ append: false })} onLoadMore={() => loadGallery({ append: true })} renderCard={renderCard} onBulkFavorite={bulkFavorite} onBulkAddToCollection={bulkAddToCollection} onBulkDownload={bulkDownload} onBulkDelete={bulkDelete} onUseUploadReference={useUploadReference} /> : section === 'Favorites' ? : section === 'Collections' ? { setSelectedCollection(null); setCollectionItems([]); }} onOpen={loadCollectionItems} onRename={renameCollection} onDelete={deleteCollection} renderCard={renderCard} /> : section === 'Models' ? diff --git a/apps/studio/src/features/jobs/JobsView.jsx b/apps/studio/src/features/jobs/JobsView.jsx index d597c7d9..92d80e05 100644 --- a/apps/studio/src/features/jobs/JobsView.jsx +++ b/apps/studio/src/features/jobs/JobsView.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { CheckCircle2, LoaderCircle, RefreshCcw, RotateCcw, X, XCircle } from 'lucide-react'; +import { CheckCircle2, LoaderCircle, RotateCcw, X, XCircle } from 'lucide-react'; import LibraryHeader from '../../components/LibraryHeader.jsx'; const STATUS_COPY = { @@ -76,10 +76,10 @@ function runtimePresentation(job) { }; } -export default function JobsView({ jobs, filter, loading, error, actionBusyId, onFilterChange, onRefresh, onJobAction }) { +export default function JobsView({ jobs, filter, loading, error, actionBusyId, onFilterChange, onJobAction }) { return (
- {loading ? : } Refresh} /> +
{[['active', 'Active'], ['queued', 'Queued'], ['running', 'Running'], ['failed', 'Failed'], ['completed', 'Completed'], ['all', 'Recent']].map(([value, label]) => ( diff --git a/apps/studio/src/features/library/FavoritesView.jsx b/apps/studio/src/features/library/FavoritesView.jsx index 229e5007..6ee93756 100644 --- a/apps/studio/src/features/library/FavoritesView.jsx +++ b/apps/studio/src/features/library/FavoritesView.jsx @@ -1,11 +1,11 @@ import React from 'react'; -import { LoaderCircle, RefreshCcw } from 'lucide-react'; +import { LoaderCircle } from 'lucide-react'; import LibraryHeader from '../../components/LibraryHeader.jsx'; -export default function FavoritesView({ items, loading, error, onRefresh, renderCard }) { +export default function FavoritesView({ items, loading, error, renderCard }) { return (
- {loading ? : } Refresh} /> + {error &&
{error}
} {loading && items.length === 0 ?
Loading favorites…
: items.length === 0 ?
No favorites yet. Tap the heart on any persisted generation.
:
{items.map((item) => renderCard(item, true))}
}
diff --git a/apps/studio/src/features/library/GalleryView.jsx b/apps/studio/src/features/library/GalleryView.jsx index 31586d28..c3a9fce3 100644 --- a/apps/studio/src/features/library/GalleryView.jsx +++ b/apps/studio/src/features/library/GalleryView.jsx @@ -1,8 +1,27 @@ import React from 'react'; import useGallerySelection from '../../hooks/useGallerySelection.js'; -import { Check, Download, FolderPlus, Heart, LoaderCircle, Plus, RefreshCcw, Trash2, X } from 'lucide-react'; +import { + ArrowDownUp, + Check, + ChevronDown, + Download, + Folder, + FolderPlus, + Heart, + LayoutGrid, + ListFilter, + LoaderCircle, + Search, + Shapes, + SlidersHorizontal, + Sparkles, + Trash2, + Upload, + X, +} from 'lucide-react'; import LibraryHeader from '../../components/LibraryHeader.jsx'; import { modelDisplayName } from '../../model-labels.js'; +import UploadsView from './UploadsView.jsx'; const DENSITY_STORAGE_KEY = 'saga.galleryDensity'; @@ -13,6 +32,9 @@ export default function GalleryView({ models, search, sort, + date, + favoritesOnly, + collections = [], page, loading, appending, @@ -21,78 +43,212 @@ export default function GalleryView({ onModelChange, onSearchChange, onSortChange, - onRefresh, + onDateChange, + onFavoritesOnlyChange, + onOpenCollection, + onOpenCollections, onLoadMore, renderCard, onBulkFavorite, onBulkAddToCollection, onBulkDownload, onBulkDelete, + onUseUploadReference, }) { const { managing, setManaging, selected, setSelected, actionBusy, toggle, finishManaging, runBulk } = useGallerySelection(items); + const [libraryTab, setLibraryTab] = React.useState('creatives'); + const [uploadSearch, setUploadSearch] = React.useState(''); const [density, setDensity] = React.useState(() => { if (typeof window === 'undefined') return 'compact'; try { return window.localStorage.getItem(DENSITY_STORAGE_KEY) === 'comfortable' ? 'comfortable' : 'compact'; } catch { return 'compact'; } }); + const [collectionsOpen, setCollectionsOpen] = React.useState(false); + const [mobileFiltersOpen, setMobileFiltersOpen] = React.useState(false); + const [mobileSearchOpen, setMobileSearchOpen] = React.useState(false); const changeDensity = (nextDensity) => { setDensity(nextDensity); try { window.localStorage.setItem(DENSITY_STORAGE_KEY, nextDensity); } catch {} }; + const hasActiveFilters = kind !== 'all' || model !== 'all' || date !== 'any' || favoritesOnly; + const resetFilters = () => { + onKindChange('all'); + onModelChange('all'); + onDateChange('any'); + onFavoritesOnlyChange(false); + }; + const toggleManaging = () => managing ? finishManaging() : setManaging(true); + const activeSearch = libraryTab === 'uploads' ? uploadSearch : search; + const updateActiveSearch = (value) => libraryTab === 'uploads' ? setUploadSearch(value) : onSearchChange(value); + const activeSearchLabel = libraryTab === 'uploads' ? 'Search uploads' : 'Search prompts'; + + const switchLibrary = (nextTab) => { + if (nextTab === libraryTab) return; + if (managing) finishManaging(); + setCollectionsOpen(false); + setMobileFiltersOpen(false); + setMobileSearchOpen(false); + setLibraryTab(nextTab); + }; + return (
{loading ? : } Refresh} + description="Browse, preview, select, and manage generated media and reusable uploaded assets." /> -
-
- {[['all', 'All'], ['image', 'Images'], ['video', 'Videos']].map(([value, label]) => )} -
-
- - - -
- - -
- + +
+
+ + +
+ + +
- {managing && ( -
- {selected.size} selected - - - - - - - -
+ {mobileSearchOpen && ( + )} - {error &&
{error}
} - {loading && items.length === 0 ? ( -
Loading Gallery…
- ) : items.length === 0 ? ( -
No media matches these filters.
+ {libraryTab === 'uploads' ? ( + ) : ( <> -
- {items.map((item) => React.cloneElement(renderCard(item, true), { - selectable: managing, - selected: selected.has(item.id), - onSelect: toggle, - }))} -
- {page.hasMore &&
} +
+
+ +
+ +
+ +
+
+ +
+ + + + + +
+ + {mobileFiltersOpen && ( +
+ + + + + {hasActiveFilters && } +
+ )} + +
+ +
+ {collectionsOpen && ( +
+ + {collections.length ? collections.map((collection) => ( + + )) : No collections yet.} +
+ )} + +
Creations
+ + {managing && ( +
+ +
+ )} + + {error &&
{error}
} + {loading && items.length === 0 ? ( +
Loading Gallery…
+ ) : items.length === 0 ? ( +
No media matches these filters.
+ ) : ( + <> +
+ {items.map((item) => React.cloneElement(renderCard(item, true), { + selectable: managing, + selected: selected.has(item.id), + onSelect: toggle, + }))} +
+ {page.hasMore &&
} + + )} )}
diff --git a/apps/studio/src/features/library/UploadsView.jsx b/apps/studio/src/features/library/UploadsView.jsx new file mode 100644 index 00000000..630b5b47 --- /dev/null +++ b/apps/studio/src/features/library/UploadsView.jsx @@ -0,0 +1,365 @@ +import React from 'react'; +import { + ArrowDownUp, + ArrowLeft, + Check, + Download, + Heart, + Image as ImageIcon, + LayoutGrid, + LoaderCircle, + MoreHorizontal, + Pencil, + Plus, + SlidersHorizontal, + Trash2, + Upload, + Video, + X, +} from 'lucide-react'; +import '../../uploads-library.css'; + +const MAX_BYTES = 25 * 1024 * 1024; +const SUPPORTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); +const DENSITY_KEY = 'saga.uploadsDensity'; + +async function readError(response, fallback) { + try { + const payload = await response.json(); + if (payload?.error) return payload.error; + } catch {} + return `${fallback} (${response.status})`; +} + +async function imageDimensions(file) { + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(file); + const result = { width: bitmap.width || 0, height: bitmap.height || 0 }; + bitmap.close?.(); + return result; + } catch {} + } + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + resolve({ width: image.naturalWidth || 0, height: image.naturalHeight || 0 }); + URL.revokeObjectURL(url); + }; + image.onerror = () => { + resolve({ width: 0, height: 0 }); + URL.revokeObjectURL(url); + }; + image.src = url; + }); +} + +function triggerDownload(asset) { + const anchor = document.createElement('a'); + anchor.href = asset.downloadUrl || asset.url; + anchor.download = asset.filename || asset.name || 'upload'; + anchor.rel = 'noopener'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +} + +function UploadAssetModal({ asset, onClose, onFavorite, onRename, onDelete, onUseReference }) { + const [menuOpen, setMenuOpen] = React.useState(false); + + React.useEffect(() => { + const onKeyDown = (event) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + if (!asset) return null; + return ( +
event.target === event.currentTarget && onClose()}> +
+
+
+ + {asset.name} + +
+
+ +
+ + {menuOpen &&
+ + + +
} +
+ +
+
+ +
+ {asset.name} +
+ +
+ + +
+
+
+ ); +} + +function UploadCard({ asset, managing, selected, onSelect, onOpen, onFavorite, onRename, onDelete }) { + const [menuOpen, setMenuOpen] = React.useState(false); + const activate = () => managing ? onSelect(asset.id) : onOpen(asset); + return ( +
+ + {managing ? ( + + ) : ( + <> + +
+ + {menuOpen &&
+ + + +
} +
+ + )} +
{asset.name}
+
+ ); +} + +export default function UploadsView({ search, onSearchChange, onUseReference }) { + const inputRef = React.useRef(null); + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(''); + const [uploading, setUploading] = React.useState(0); + const [sort, setSort] = React.useState('newest'); + const [favoritesOnly, setFavoritesOnly] = React.useState(false); + const [managing, setManaging] = React.useState(false); + const [selected, setSelected] = React.useState(new Set()); + const [selectedAsset, setSelectedAsset] = React.useState(null); + const [mobileFiltersOpen, setMobileFiltersOpen] = React.useState(false); + const [actionBusy, setActionBusy] = React.useState(''); + const [density, setDensity] = React.useState(() => { + try { return window.localStorage.getItem(DENSITY_KEY) === 'comfortable' ? 'comfortable' : 'compact'; } + catch { return 'compact'; } + }); + + const loadUploads = React.useCallback(async ({ silent = false } = {}) => { + if (!silent) setLoading(true); + try { + const params = new URLSearchParams({ limit: '100', sort }); + if (favoritesOnly) params.set('favorite', 'true'); + if (search?.trim()) params.set('search', search.trim()); + const response = await fetch(`/api/uploads?${params.toString()}`, { headers: { Accept: 'application/json' }, cache: 'no-store' }); + if (!response.ok) throw new Error(await readError(response, 'Could not load uploads')); + const payload = await response.json(); + setItems(Array.isArray(payload?.items) ? payload.items : []); + setError(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not load uploads.'); + } finally { + if (!silent) setLoading(false); + } + }, [favoritesOnly, search, sort]); + + React.useEffect(() => { loadUploads(); }, [loadUploads]); + + React.useEffect(() => { + const refresh = () => { if (document.visibilityState === 'visible') loadUploads({ silent: true }); }; + const timer = window.setInterval(refresh, 5000); + window.addEventListener('focus', refresh); + document.addEventListener('visibilitychange', refresh); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', refresh); + document.removeEventListener('visibilitychange', refresh); + }; + }, [loadUploads]); + + React.useEffect(() => { + const ids = new Set(items.map((item) => item.id)); + setSelected((current) => new Set([...current].filter((id) => ids.has(id)))); + if (selectedAsset) { + const fresh = items.find((item) => item.id === selectedAsset.id); + if (fresh) setSelectedAsset(fresh); + } + }, [items]); + + const changeDensity = (next) => { + setDensity(next); + try { window.localStorage.setItem(DENSITY_KEY, next); } catch {} + }; + + const uploadFiles = async (fileList) => { + const files = Array.from(fileList || []); + if (!files.length) return; + setUploading(files.length); + setError(''); + try { + for (const file of files) { + if (!SUPPORTED_TYPES.has(file.type)) throw new Error(`${file.name} is not PNG, JPEG, or WebP.`); + if (!file.size || file.size > MAX_BYTES) throw new Error(`${file.name} must be smaller than 25 MB.`); + const dimensions = await imageDimensions(file); + const ticketResponse = await fetch('/api/uploads', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size, purpose: 'library-upload' }), + }); + if (!ticketResponse.ok) throw new Error(await readError(ticketResponse, 'Could not prepare upload')); + const ticket = await ticketResponse.json(); + const putResponse = await fetch(ticket.uploadUrl, { method: 'PUT', headers: { 'Content-Type': ticket.contentType || file.type }, body: file }); + if (!putResponse.ok) throw new Error(`Direct upload failed for ${file.name} (${putResponse.status})`); + const completeResponse = await fetch('/api/uploads', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'complete', key: ticket.key, filename: file.name, displayName: file.name.replace(/\.[^.]+$/, ''), contentType: file.type, size: file.size, ...dimensions }), + }); + if (!completeResponse.ok) throw new Error(await readError(completeResponse, 'Could not save upload')); + setUploading((count) => Math.max(0, count - 1)); + } + await loadUploads(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed.'); + } finally { + setUploading(0); + if (inputRef.current) inputRef.current.value = ''; + } + }; + + const patchAsset = async (asset, patch) => { + const response = await fetch('/api/uploads', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: asset.id, ...patch }) }); + if (!response.ok) throw new Error(await readError(response, 'Could not update upload')); + const payload = await response.json(); + setItems((current) => current.map((item) => item.id === asset.id ? payload.item : item)); + return payload.item; + }; + + const toggleFavorite = async (asset) => { + try { await patchAsset(asset, { favorite: !asset.favorite }); } + catch (err) { setError(err instanceof Error ? err.message : 'Could not update favorite.'); } + }; + + const renameAsset = async (asset) => { + const next = window.prompt('Rename upload', asset.name); + if (next == null || !next.trim() || next.trim() === asset.name) return; + try { await patchAsset(asset, { displayName: next.trim() }); } + catch (err) { setError(err instanceof Error ? err.message : 'Could not rename upload.'); } + }; + + const deleteAsset = async (asset, { skipConfirm = false } = {}) => { + if (!skipConfirm && !window.confirm(`Delete “${asset.name}” permanently?`)) return false; + const response = await fetch(`/api/uploads?id=${encodeURIComponent(asset.id)}`, { method: 'DELETE' }); + if (!response.ok) throw new Error(await readError(response, 'Could not delete upload')); + setItems((current) => current.filter((item) => item.id !== asset.id)); + setSelected((current) => { const next = new Set(current); next.delete(asset.id); return next; }); + if (selectedAsset?.id === asset.id) setSelectedAsset(null); + return true; + }; + + const removeAsset = async (asset) => { + try { await deleteAsset(asset); } + catch (err) { setError(err instanceof Error ? err.message : 'Could not delete upload.'); } + }; + + const toggleSelected = (id) => setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + + const exitManage = () => { setManaging(false); setSelected(new Set()); }; + + const runBulk = async (action) => { + const chosen = items.filter((item) => selected.has(item.id)); + if (!chosen.length || actionBusy) return; + if (action === 'delete' && !window.confirm(`Delete ${chosen.length} selected upload${chosen.length === 1 ? '' : 's'} permanently?`)) return; + setActionBusy(action); + setError(''); + try { + if (action === 'download') { + chosen.forEach((asset, index) => window.setTimeout(() => triggerDownload(asset), index * 180)); + } else if (action === 'favorite') { + await Promise.all(chosen.map((asset) => patchAsset(asset, { favorite: true }))); + } else if (action === 'delete') { + for (const asset of chosen) await deleteAsset(asset, { skipConfirm: true }); + } + if (action !== 'download') await loadUploads({ silent: true }); + if (action === 'delete') exitManage(); + } catch (err) { + setError(err instanceof Error ? err.message : `Could not ${action} selected uploads.`); + } finally { + setActionBusy(''); + } + }; + + const useReference = async (asset, targetMode) => { + try { + await onUseReference?.(asset, targetMode); + setSelectedAsset(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not use this upload as a reference.'); + } + }; + + return ( +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + + +
+ + {mobileFiltersOpen &&
} + + {managing &&
+ + + + + +
} + + {error &&
{error}
} + +
+ + uploadFiles(event.target.files)} /> + {items.map((asset) => ) } +
+ + {loading && !items.length &&
Loading uploads…
} + {!loading && !items.length && !search?.trim() && !favoritesOnly &&
Your uploaded references will stay here for reuse.
} + {!loading && !items.length && (search?.trim() || favoritesOnly) &&
No uploads match these filters.
} + + setSelectedAsset(null)} onFavorite={toggleFavorite} onRename={renameAsset} onDelete={removeAsset} onUseReference={useReference}/> +
+ ); +} diff --git a/apps/studio/src/gallery-controls.css b/apps/studio/src/gallery-controls.css index 46a474ec..88811a9b 100644 --- a/apps/studio/src/gallery-controls.css +++ b/apps/studio/src/gallery-controls.css @@ -137,7 +137,7 @@ .gallery-view .gallery-card{width:100%;transition:border-color .16s ease,transform .16s ease,box-shadow .16s ease} .gallery-view .gallery-card.selected{border-color:rgba(129,102,255,.82);box-shadow:0 0 0 2px rgba(177,163,255,.38),0 8px 28px rgba(0,0,0,.22)} .gallery-view .gallery-card .media-frame{aspect-ratio:1;background:#0b0f15} -.gallery-view .gallery-card .media-frame-image{background-size:contain;background-repeat:no-repeat;background-position:center} +.gallery-view .gallery-card .media-frame-image{background-size:cover;background-repeat:no-repeat;background-position:center} .gallery-density-comfortable .gallery-card .history-copy{padding:12px 13px 14px} .gallery-density-comfortable .gallery-card .history-prompt{font-size:var(--saga-text-lg);line-height:1.45} .gallery-density-comfortable .gallery-card .history-meta{margin-top:9px;font-size:var(--saga-text-md)} diff --git a/apps/studio/src/gallery-library-redesign.css b/apps/studio/src/gallery-library-redesign.css new file mode 100644 index 00000000..2887856c --- /dev/null +++ b/apps/studio/src/gallery-library-redesign.css @@ -0,0 +1,418 @@ +.gallery-library-nav{ + display:flex; + align-items:center; + justify-content:space-between; + gap:24px; + min-height:58px; + margin-top:2px; + border-bottom:1px solid rgba(255,255,255,.09); +} + +.gallery-primary-tabs{ + align-self:stretch; + display:flex; + align-items:stretch; + gap:28px; + min-width:0; +} + +.gallery-primary-tabs button{ + position:relative; + display:flex; + align-items:center; + gap:8px; + padding:0 2px; + border:0; + background:transparent; + color:#929aa8; + font-size:var(--saga-text-md); + font-weight:500; + cursor:pointer; +} + +.gallery-primary-tabs button:hover:not(:disabled){color:#f4f6f9} +.gallery-primary-tabs button.selected{color:#fff;font-weight:700} +.gallery-primary-tabs button.selected::after{ + content:''; + position:absolute; + left:0; + right:0; + bottom:-1px; + height:2px; + border-radius:2px 2px 0 0; + background:#f5f6fa; +} +.gallery-primary-tabs button:disabled{opacity:.38;cursor:not-allowed} + +.gallery-search{ + display:flex; + align-items:center; + gap:9px; + min-width:0; + height:40px; + padding:0 11px; + border:1px solid rgba(255,255,255,.12); + border-radius:9px; + background:#0d1117; + color:#818a99; +} +.gallery-search:focus-within{border-color:rgba(177,163,255,.58);box-shadow:0 0 0 2px rgba(126,99,255,.10)} +.gallery-search input{ + width:220px; + height:100%; + padding:0; + border:0; + outline:0; + background:transparent; + color:#eef1f5; + font:inherit; +} +.gallery-search input::placeholder{color:#747d8d} +.gallery-search-mobile,.gallery-mobile-search-trigger{display:none} + +.gallery-desktop-controls{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; + min-height:58px; + border-bottom:1px solid rgba(255,255,255,.07); +} +.gallery-filter-strip,.gallery-view-strip{ + display:flex; + align-items:center; + min-width:0; + gap:10px; +} +.gallery-filter-strip{flex:1;overflow:hidden} +.gallery-view-strip{justify-content:flex-end;white-space:nowrap} +.gallery-control-divider{width:1px;height:20px;background:rgba(255,255,255,.10);flex:0 0 auto} + +.gallery-reset-filter,.gallery-manage-trigger{ + min-height:36px; + padding:0 8px; + border:0; + border-radius:7px; + background:transparent; + color:#aeb5c0; + font-size:var(--saga-text-sm); + cursor:pointer; +} +.gallery-reset-filter:hover,.gallery-manage-trigger:hover{color:#fff;background:rgba(255,255,255,.05)} +.gallery-reset-filter.selected{color:#fff;font-weight:700} +.gallery-manage-trigger{display:flex;align-items:center;gap:7px} +.gallery-manage-trigger.active{color:#b8ffb1;background:rgba(95,226,95,.08)} + +.gallery-inline-select{ + display:inline-flex; + align-items:center; + gap:6px; + min-width:0; + color:#9ca4b0; + font-size:var(--saga-text-sm); +} +.gallery-inline-select select{ + max-width:180px; + min-width:0; + height:36px; + padding:0 26px 0 5px; + border:0; + outline:0; + background:transparent; + color:#aeb5c0; + font:inherit; + cursor:pointer; +} +.gallery-inline-select select:hover,.gallery-inline-select select:focus{color:#fff} +.gallery-inline-select option{background:#11161e;color:#eef1f5} +.gallery-model-filter select{max-width:170px} + +.gallery-favorites-filter{ + display:inline-flex; + align-items:center; + gap:7px; + min-height:36px; + color:#aeb5c0; + font-size:var(--saga-text-sm); + cursor:pointer; +} +.gallery-favorites-filter input{width:15px;height:15px;accent-color:#7a5cff} +.gallery-favorites-filter:has(input:checked){color:#fff} +.gallery-favorites-filter:has(input:checked) svg{color:#c8baff} + +.gallery-sort{position:relative} +.gallery-sort select{max-width:100px} +.gallery-density-control{ + display:inline-flex; + align-items:center; + gap:3px; + height:36px; + padding:0; + border:0; + border-radius:0; + background:transparent; +} +.gallery-density-control>svg{margin-right:2px;color:#949dab} +.gallery-density-control button{ + height:32px; + padding:0 7px; + border:0; + border-radius:7px; + background:transparent; + color:#8f98a6; + font-size:var(--saga-text-xs); + cursor:pointer; +} +.gallery-density-control button:hover{color:#fff;background:rgba(255,255,255,.05)} +.gallery-density-control button.selected{color:#fff;background:rgba(122,92,255,.12);box-shadow:none;font-weight:700} + +.gallery-mobile-controls{display:none} + +.gallery-collections-row{ + display:flex; + align-items:center; + min-height:62px; +} +.gallery-collections-trigger{ + display:inline-flex; + align-items:center; + gap:7px; + min-height:36px; + padding:0 6px; + border:0; + border-radius:7px; + background:transparent; + color:#c8cdd5; + font-size:var(--saga-text-md); + font-weight:650; + cursor:pointer; +} +.gallery-collections-trigger:hover{color:#fff;background:rgba(255,255,255,.04)} +.gallery-collections-trigger svg:first-child{color:#969eaa} +.gallery-collections-trigger .gallery-collections-count{color:#747d8a;font-weight:500} +.gallery-collections-trigger svg:last-child{margin-left:2px;transition:transform .16s ease} +.gallery-collections-trigger svg:last-child.open{transform:rotate(180deg)} + +.gallery-collections-panel{ + display:flex; + align-items:stretch; + gap:9px; + margin:-4px 0 18px; + padding:10px; + overflow-x:auto; + border:1px solid rgba(255,255,255,.09); + border-radius:12px; + background:#0c1016; + scrollbar-width:thin; +} +.gallery-collections-panel>button{ + display:flex; + align-items:center; + gap:9px; + min-width:170px; + max-width:235px; + min-height:46px; + padding:6px 10px; + border:1px solid rgba(255,255,255,.08); + border-radius:9px; + background:#11161e; + color:#d8dce3; + cursor:pointer; +} +.gallery-collections-panel>button:hover{border-color:rgba(177,163,255,.34);background:#151b24;color:#fff} +.gallery-collection-thumb{ + width:32px; + height:32px; + flex:0 0 32px; + display:grid; + place-items:center; + border-radius:7px; + background:#171e28 center/cover no-repeat; + color:#9ba4b2; +} +.gallery-collection-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1} +.gallery-collection-count{color:#7e8795;font-size:var(--saga-text-xs)} +.gallery-collection-all{border-style:dashed!important} +.gallery-collections-empty{display:flex;align-items:center;padding:0 8px;color:#798290;font-size:var(--saga-text-sm)} +.gallery-content-heading{margin:0 0 14px;color:#f0f2f6;font-size:var(--saga-text-md);font-weight:700} + +.gallery-manager{ + position:fixed!important; + left:50%!important; + right:auto!important; + top:auto!important; + bottom:max(18px,env(safe-area-inset-bottom))!important; + z-index:85!important; + width:auto!important; + max-width:calc(100vw - 32px)!important; + transform:translateX(-50%); + display:flex!important; + align-items:center!important; + gap:5px!important; + margin:0!important; + padding:8px!important; + border:1px solid rgba(255,255,255,.14)!important; + border-radius:13px!important; + background:rgba(25,29,37,.96)!important; + box-shadow:0 18px 52px rgba(0,0,0,.48)!important; + backdrop-filter:blur(18px)!important; +} +.gallery-manager button{ + height:38px!important; + display:flex!important; + align-items:center!important; + justify-content:center!important; + gap:7px!important; + min-width:0!important; + padding:0 12px!important; + border:0!important; + border-radius:8px!important; + background:transparent!important; + color:#d8dde5!important; + font-size:var(--saga-text-sm)!important; + cursor:pointer; +} +.gallery-manager button:hover:not(:disabled){background:rgba(255,255,255,.07)!important;color:#fff!important} +.gallery-manager button:disabled{opacity:.38!important;cursor:not-allowed!important} +.gallery-manager button.danger{color:#ffb7c2!important} +.gallery-manager button.danger:hover:not(:disabled){background:rgba(255,91,113,.10)!important;color:#ffd2d8!important} +.gallery-manager .gallery-select-all{padding-left:10px!important} +.gallery-manager .gallery-select-all strong{min-width:0!important;font-size:var(--saga-text-xs)!important;color:#9fa7b4!important;font-weight:600!important} +.gallery-manager-divider{width:1px;height:24px;background:rgba(255,255,255,.12);flex:0 0 auto} +.gallery-manager-close{width:36px!important;padding:0!important;color:#aab2bf!important} +.gallery-view:has(.gallery-manager){padding-bottom:78px} + +.gallery-mobile-filter-panel{display:none} + +@media(max-width:1120px) and (min-width:761px){ + .gallery-desktop-controls{align-items:flex-start;flex-direction:column;padding:10px 0 12px} + .gallery-filter-strip{width:100%;overflow-x:auto;padding-bottom:2px} + .gallery-view-strip{width:100%;justify-content:flex-start} + .gallery-search input{width:180px} +} + +@media(max-width:760px){ + .gallery-view .library-header{margin-bottom:8px} + .gallery-library-nav{min-height:54px;gap:12px;margin-top:0} + .gallery-primary-tabs{gap:20px;overflow-x:auto;scrollbar-width:none} + .gallery-primary-tabs::-webkit-scrollbar{display:none} + .gallery-primary-tabs button{min-height:54px;font-size:var(--saga-text-sm);white-space:nowrap} + .gallery-primary-tabs button svg{display:none} + .gallery-search-desktop,.gallery-desktop-controls,.gallery-collections-row{display:none!important} + .gallery-mobile-search-trigger{ + flex:0 0 42px; + width:42px; + height:42px; + display:grid; + place-items:center; + border:0; + border-radius:9px; + background:transparent; + color:#abb3bf; + } + .gallery-mobile-search-trigger.active{background:rgba(122,92,255,.12);color:#fff} + .gallery-search-mobile{display:flex;margin:10px 0 0;width:100%;box-sizing:border-box} + .gallery-search-mobile input{width:100%} + + .gallery-mobile-controls{ + display:grid; + grid-template-columns:repeat(5,minmax(0,1fr)); + align-items:stretch; + gap:2px; + min-height:66px; + margin:0 0 10px; + border-bottom:1px solid rgba(255,255,255,.08); + } + .gallery-mobile-controls>button,.gallery-mobile-select{ + position:relative; + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:4px; + min-width:0; + min-height:58px; + padding:4px 2px; + border:0; + border-radius:8px; + background:transparent; + color:#969eaa; + font-size:10px; + } + .gallery-mobile-controls>button.active{color:#fff;background:rgba(122,92,255,.10)} + .gallery-mobile-select select{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer} + + .gallery-mobile-filter-panel{ + display:grid; + gap:9px; + margin:0 0 12px; + padding:12px; + border:1px solid rgba(255,255,255,.10); + border-radius:12px; + background:#0d1219; + } + .gallery-mobile-filter-panel>label{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + min-height:42px; + color:#aeb5c0; + font-size:var(--saga-text-sm); + } + .gallery-mobile-filter-panel select{ + min-width:155px; + max-width:66%; + height:38px; + padding:0 28px 0 9px; + border:1px solid rgba(255,255,255,.11); + border-radius:8px; + background:#111720; + color:#eef1f5; + } + .gallery-mobile-filter-panel .gallery-model-filter select{width:auto;min-width:155px;max-width:66%} + .gallery-mobile-favorite{justify-content:flex-start!important} + .gallery-mobile-favorite input{width:17px;height:17px;accent-color:#7a5cff} + .gallery-clear-filters{ + min-height:40px; + border:1px solid rgba(255,255,255,.10); + border-radius:8px; + background:#151b24; + color:#dfe3e9; + } + + .gallery-collections-panel{margin:0 0 14px;padding:8px} + .gallery-collections-panel>button{min-width:150px} + .gallery-content-heading{margin-top:2px} + + .gallery-manager{ + left:12px!important; + right:12px!important; + bottom:calc(10px + env(safe-area-inset-bottom))!important; + width:auto!important; + max-width:none!important; + transform:none!important; + display:grid!important; + grid-template-columns:1.2fr repeat(4,minmax(0,1fr)) 38px!important; + gap:3px!important; + padding:7px!important; + border-radius:14px!important; + } + .gallery-manager button{height:46px!important;padding:0 5px!important;flex-direction:column!important;gap:2px!important;font-size:9px!important} + .gallery-manager button svg{width:17px;height:17px} + .gallery-manager .gallery-select-all{padding:0 5px!important} + .gallery-manager .gallery-select-all strong{font-size:9px!important} + .gallery-manager-divider{display:none} + .gallery-manager-close{width:38px!important} + .gallery-view:has(.gallery-manager){padding-bottom:92px} +} + +@media(max-width:390px){ + .gallery-primary-tabs{gap:15px} + .gallery-primary-tabs button{font-size:12px} + .gallery-mobile-controls>button,.gallery-mobile-select{font-size:9px} + .gallery-manager{grid-template-columns:1.1fr repeat(4,minmax(0,1fr)) 34px!important;left:8px!important;right:8px!important} + .gallery-manager button span{max-width:54px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +} + +@media(max-width:760px){.gallery-manager button::after{content:none!important}} diff --git a/apps/studio/src/hooks/useLibraryController.js b/apps/studio/src/hooks/useLibraryController.js index 818317d1..620b8785 100644 --- a/apps/studio/src/hooks/useLibraryController.js +++ b/apps/studio/src/hooks/useLibraryController.js @@ -2,6 +2,7 @@ import React from 'react'; import { fetchCollectionItems, fetchCollections, fetchFavorites, fetchGallery } from '../api/studioApi.js'; const GALLERY_PAGE_SIZE = 24; +const AUTO_REFRESH_MS = 5000; export default function useLibraryController({ section, toGalleryItem }) { const [favorites, setFavorites] = React.useState(new Set()); @@ -14,6 +15,8 @@ export default function useLibraryController({ section, toGalleryItem }) { const [galleryModel, setGalleryModel] = React.useState('all'); const [gallerySearch, setGallerySearch] = React.useState(''); const [gallerySort, setGallerySort] = React.useState('newest'); + const [galleryDate, setGalleryDate] = React.useState('any'); + const [galleryFavoritesOnly, setGalleryFavoritesOnly] = React.useState(false); const [galleryModels, setGalleryModels] = React.useState([]); const [galleryPage, setGalleryPage] = React.useState({ nextOffset: null, hasMore: false }); const [libraryLoading, setLibraryLoading] = React.useState(false); @@ -22,12 +25,41 @@ export default function useLibraryController({ section, toGalleryItem }) { const [selectedCollection, setSelectedCollection] = React.useState(null); const [collectionItems, setCollectionItems] = React.useState([]); - const loadGallery = async ({ append = false, kind = galleryKind, model = galleryModel, search = gallerySearch, sort = gallerySort } = {}) => { + const galleryItemsRef = React.useRef([]); + const selectedCollectionRef = React.useRef(null); + const refreshInFlightRef = React.useRef(false); + + React.useEffect(() => { galleryItemsRef.current = galleryItems; }, [galleryItems]); + React.useEffect(() => { selectedCollectionRef.current = selectedCollection; }, [selectedCollection]); + + const loadGallery = async ({ + append = false, + silent = false, + preserveLoaded = false, + kind = galleryKind, + model = galleryModel, + search = gallerySearch, + sort = gallerySort, + date = galleryDate, + favoritesOnly = galleryFavoritesOnly, + } = {}) => { if (append && galleryPage.nextOffset == null) return; - append ? setGalleryAppending(true) : setGalleryLoading(true); + if (!silent) append ? setGalleryAppending(true) : setGalleryLoading(true); setGalleryError(''); try { - const payload = await fetchGallery({ limit: GALLERY_PAGE_SIZE, offset: append ? galleryPage.nextOffset : 0, kind, model, search, sort }); + const refreshLimit = preserveLoaded + ? Math.max(GALLERY_PAGE_SIZE, galleryItemsRef.current.length || 0) + : GALLERY_PAGE_SIZE; + const payload = await fetchGallery({ + limit: append ? GALLERY_PAGE_SIZE : refreshLimit, + offset: append ? galleryPage.nextOffset : 0, + kind, + model, + search, + sort, + date, + favoritesOnly, + }); const nextItems = (Array.isArray(payload?.items) ? payload.items : []).map(toGalleryItem); setGalleryItems((current) => append ? [...current, ...nextItems] : nextItems); setFavorites((current) => { @@ -37,30 +69,64 @@ export default function useLibraryController({ section, toGalleryItem }) { }); setGalleryPage({ nextOffset: payload?.page?.nextOffset ?? null, hasMore: Boolean(payload?.page?.hasMore) }); if (Array.isArray(payload?.facets?.models)) setGalleryModels(payload.facets.models); - } catch (err) { setGalleryError(err instanceof Error ? err.message : 'Unable to load Gallery.'); } - finally { append ? setGalleryAppending(false) : setGalleryLoading(false); } + } catch (err) { + setGalleryError(err instanceof Error ? err.message : 'Unable to load Gallery.'); + } finally { + if (!silent) append ? setGalleryAppending(false) : setGalleryLoading(false); + } }; - const loadFavorites = async () => { - setLibraryLoading(true); setLibraryError(''); + const loadFavorites = async ({ silent = false } = {}) => { + if (!silent) setLibraryLoading(true); + setLibraryError(''); try { const payload = await fetchFavorites(); const nextItems = (Array.isArray(payload?.items) ? payload.items : []).map(toGalleryItem); setFavoriteItems(nextItems); setFavorites(new Set(nextItems.map((item) => item.id))); - } catch (err) { setLibraryError(err instanceof Error ? err.message : 'Unable to load favorites.'); } - finally { setLibraryLoading(false); } + } catch (err) { + setLibraryError(err instanceof Error ? err.message : 'Unable to load favorites.'); + } finally { + if (!silent) setLibraryLoading(false); + } }; - const loadCollections = async () => { - setLibraryLoading(true); setLibraryError(''); - try { const payload = await fetchCollections(); setCollections(Array.isArray(payload?.collections) ? payload.collections : []); } - catch (err) { setLibraryError(err instanceof Error ? err.message : 'Unable to load collections.'); } - finally { setLibraryLoading(false); } + const loadCollections = async ({ silent = false } = {}) => { + if (!silent) setLibraryLoading(true); + setLibraryError(''); + try { + const payload = await fetchCollections(); + const nextCollections = Array.isArray(payload?.collections) ? payload.collections : []; + setCollections(nextCollections); + const currentSelection = selectedCollectionRef.current; + if (currentSelection?.id) { + const refreshedSelection = nextCollections.find((collection) => collection.id === currentSelection.id) || null; + if (refreshedSelection) { + setSelectedCollection(refreshedSelection); + selectedCollectionRef.current = refreshedSelection; + } else { + setSelectedCollection(null); + selectedCollectionRef.current = null; + setCollectionItems([]); + } + } + return nextCollections; + } catch (err) { + setLibraryError(err instanceof Error ? err.message : 'Unable to load collections.'); + return null; + } finally { + if (!silent) setLibraryLoading(false); + } }; - const loadCollectionItems = async (collection) => { - setSelectedCollection(collection); setLibraryLoading(true); setLibraryError(''); + const loadCollectionItems = async (collection, { silent = false, select = true } = {}) => { + if (!collection?.id) return; + if (select) { + setSelectedCollection(collection); + selectedCollectionRef.current = collection; + } + if (!silent) setLibraryLoading(true); + setLibraryError(''); try { const payload = await fetchCollectionItems(collection.id); const nextItems = (Array.isArray(payload?.items) ? payload.items : []).map(toGalleryItem); @@ -70,15 +136,84 @@ export default function useLibraryController({ section, toGalleryItem }) { nextItems.forEach((item) => item.favorite ? next.add(item.id) : next.delete(item.id)); return next; }); - } catch (err) { setLibraryError(err instanceof Error ? err.message : 'Unable to load collection.'); } - finally { setLibraryLoading(false); } + } catch (err) { + setLibraryError(err instanceof Error ? err.message : 'Unable to load collection.'); + } finally { + if (!silent) setLibraryLoading(false); + } }; React.useEffect(() => { - if (section === 'Gallery') loadGallery({ append: false, kind: galleryKind, model: galleryModel }); - if (section === 'Favorites') loadFavorites(); - if (section === 'Collections') { setSelectedCollection(null); setCollectionItems([]); loadCollections(); } - }, [section, galleryKind, galleryModel, gallerySearch, gallerySort]); + if (!['Gallery', 'Favorites', 'Collections'].includes(section)) return undefined; - return { favorites, setFavorites, favoriteItems, setFavoriteItems, galleryItems, setGalleryItems, galleryLoading, galleryAppending, galleryError, galleryKind, setGalleryKind, galleryModel, setGalleryModel, gallerySearch, setGallerySearch, gallerySort, setGallerySort, galleryModels, galleryPage, libraryLoading, libraryError, setLibraryError, collections, setCollections, selectedCollection, setSelectedCollection, collectionItems, setCollectionItems, loadGallery, loadFavorites, loadCollections, loadCollectionItems }; + let disposed = false; + const refresh = async ({ initial = false } = {}) => { + if (disposed || (typeof document !== 'undefined' && document.visibilityState === 'hidden')) return; + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; + try { + if (section === 'Gallery') { + await Promise.all([ + loadGallery({ + append: false, + silent: !initial, + preserveLoaded: !initial, + kind: galleryKind, + model: galleryModel, + search: gallerySearch, + sort: gallerySort, + date: galleryDate, + favoritesOnly: galleryFavoritesOnly, + }), + loadCollections({ silent: true }), + ]); + } else if (section === 'Favorites') { + await loadFavorites({ silent: !initial }); + } else if (section === 'Collections') { + const nextCollections = await loadCollections({ silent: !initial }); + const currentSelection = selectedCollectionRef.current; + if (nextCollections && currentSelection?.id && nextCollections.some((collection) => collection.id === currentSelection.id)) { + await loadCollectionItems(currentSelection, { silent: true, select: false }); + } + } + } finally { + refreshInFlightRef.current = false; + } + }; + + refresh({ initial: true }); + const timer = window.setInterval(() => refresh(), AUTO_REFRESH_MS); + const onFocus = () => refresh(); + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') refresh(); + }; + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + disposed = true; + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [section, galleryKind, galleryModel, gallerySearch, gallerySort, galleryDate, galleryFavoritesOnly, selectedCollection?.id]); + + return { + favorites, setFavorites, + favoriteItems, setFavoriteItems, + galleryItems, setGalleryItems, + galleryLoading, galleryAppending, galleryError, + galleryKind, setGalleryKind, + galleryModel, setGalleryModel, + gallerySearch, setGallerySearch, + gallerySort, setGallerySort, + galleryDate, setGalleryDate, + galleryFavoritesOnly, setGalleryFavoritesOnly, + galleryModels, galleryPage, + libraryLoading, libraryError, setLibraryError, + collections, setCollections, + selectedCollection, setSelectedCollection, + collectionItems, setCollectionItems, + loadGallery, loadFavorites, loadCollections, loadCollectionItems, + }; } diff --git a/apps/studio/src/uploads-library.css b/apps/studio/src/uploads-library.css new file mode 100644 index 00000000..21c0bc8f --- /dev/null +++ b/apps/studio/src/uploads-library.css @@ -0,0 +1,108 @@ +.uploads-library{min-width:0} +.uploads-desktop-controls{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:14px 0 18px;padding:0 2px} +.uploads-filter-strip,.uploads-view-strip{display:flex;align-items:center;gap:10px;min-width:0} +.uploads-filter-strip>button,.uploads-filter-strip>label,.uploads-view-strip>button,.uploads-view-strip>label,.uploads-density{min-height:40px;display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;color:#b7bdc7;font-size:var(--saga-text-sm)} +.uploads-filter-strip>button,.uploads-view-strip>button{padding:0 8px;cursor:pointer} +.uploads-filter-strip>button:hover,.uploads-view-strip>button:hover{color:#fff} +.uploads-filter-strip>button.selected,.uploads-view-strip>button.active{color:#fff;font-weight:700} +.uploads-filter-strip>label{cursor:pointer;padding:0 5px} +.uploads-filter-strip input{width:16px;height:16px;accent-color:#7a5cff} +.uploads-view-strip>label select{height:38px;padding:0 27px 0 7px;border:0;background:transparent;color:#e7eaf0;outline:none;cursor:pointer} +.uploads-view-strip>label select:focus-visible{outline:var(--saga-focus-ring);outline-offset:2px;border-radius:6px} +.uploads-density{height:40px;padding-left:4px;border-left:1px solid rgba(255,255,255,.09)} +.uploads-density button{height:32px;padding:0 8px;border:0;border-radius:7px;background:transparent;color:#929aa6;cursor:pointer;font-size:var(--saga-text-xs)} +.uploads-density button:hover,.uploads-density button.selected{background:#171c24;color:#fff} +.uploads-mobile-controls,.uploads-mobile-filter-panel{display:none} +.uploads-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));justify-content:start;gap:12px;align-items:start} +.uploads-density-comfortable .uploads-grid{grid-template-columns:repeat(auto-fill,minmax(250px,320px));gap:18px} +.upload-add-card,.upload-asset-card{position:relative;min-width:0;aspect-ratio:1;border:1px solid rgba(255,255,255,.08);border-radius:13px;overflow:hidden;background:#13171d;box-shadow:0 7px 20px rgba(0,0,0,.16)} +.upload-add-card{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:#a9afb9;cursor:pointer;font-size:var(--saga-text-md);transition:background .16s ease,border-color .16s ease,color .16s ease} +.upload-add-card:hover:not(:disabled){background:#191e25;border-color:rgba(255,255,255,.15);color:#fff} +.upload-add-card:focus-visible{outline:var(--saga-focus-ring);outline-offset:3px} +.upload-add-card:disabled{cursor:wait} +.upload-asset-card{transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease} +.upload-asset-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 11px 30px rgba(0,0,0,.26)} +.upload-asset-card.selected{border-color:rgba(132,106,255,.88);box-shadow:0 0 0 2px rgba(177,163,255,.35),0 10px 30px rgba(0,0,0,.24)} +.upload-asset-primary{position:absolute;inset:0;width:100%;height:100%;padding:0;border:0;background:#0b0e13;cursor:pointer} +.upload-asset-primary img{width:100%;height:100%;display:block;object-fit:cover;object-position:center} +.upload-asset-primary:focus-visible{outline:var(--saga-focus-ring);outline-offset:-3px} +.upload-favorite,.upload-card-menu-trigger,.upload-selection-mark{position:absolute;z-index:4;top:8px;width:34px;height:34px;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(10,13,18,.72);backdrop-filter:blur(10px);color:#eff2f6;display:grid;place-items:center} +.upload-favorite{right:48px;cursor:pointer} +.upload-favorite:hover,.upload-favorite.active{background:rgba(18,22,30,.93);color:#d2c7ff} +.upload-card-menu-wrap{position:absolute;top:8px;right:8px;z-index:6} +.upload-card-menu-trigger{position:static;cursor:pointer} +.upload-card-menu-trigger:hover{background:rgba(18,22,30,.93)} +.upload-selection-mark{right:8px;pointer-events:none} +.upload-selection-mark.selected{border-color:#a993ff;background:#7356df;color:#fff} +.upload-card-caption{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:24px 10px 9px;background:linear-gradient(to bottom,transparent,rgba(5,7,10,.88));color:#f4f5f8;font-size:var(--saga-text-xs);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none} +.upload-card-menu{position:absolute;right:0;top:42px;width:154px;padding:6px;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:#171b22;box-shadow:0 16px 44px rgba(0,0,0,.48);z-index:12} +.upload-card-menu button{width:100%;min-height:36px;display:flex;align-items:center;gap:9px;padding:0 9px;border:0;border-radius:7px;background:transparent;color:#d7dbe3;cursor:pointer;font-size:var(--saga-text-sm);text-align:left} +.upload-card-menu button:hover{background:rgba(255,255,255,.07);color:#fff} +.upload-card-menu button.danger{color:#ffb1bd} +.upload-card-menu button:focus-visible{outline:var(--saga-focus-ring);outline-offset:-2px} +.uploads-loading,.uploads-empty{display:flex;align-items:center;justify-content:center;gap:9px;min-height:96px;color:#8f97a3;font-size:var(--saga-text-sm)} +.uploads-manager{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:75;display:flex;align-items:center;gap:5px;padding:7px;border:1px solid rgba(255,255,255,.13);border-radius:14px;background:rgba(13,17,23,.96);box-shadow:0 18px 46px rgba(0,0,0,.46);backdrop-filter:blur(16px)} +.uploads-manager button{height:40px;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 10px;border:1px solid transparent;border-radius:9px;background:transparent;color:#d5dae2;font-size:var(--saga-text-xs);cursor:pointer;white-space:nowrap} +.uploads-manager button:hover:not(:disabled){background:#20252d;color:#fff} +.uploads-manager button:disabled{opacity:.45;cursor:not-allowed} +.uploads-manager button.danger{color:#ffb0bd} +.uploads-manager strong{font-size:10px;color:#929aa6;font-weight:600} +.upload-detail-backdrop{position:fixed;inset:0;z-index:120;background:rgba(3,5,8,.84);backdrop-filter:blur(7px);display:grid;place-items:center;padding:18px} +.upload-detail{width:min(1180px,calc(100vw - 36px));height:min(900px,calc(100vh - 36px));display:grid;grid-template-rows:auto minmax(0,1fr) auto;border:1px solid rgba(255,255,255,.08);border-radius:15px;background:#0f1217;box-shadow:0 28px 90px rgba(0,0,0,.62);overflow:hidden} +.upload-detail-header{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:9px 14px;border-bottom:1px solid rgba(255,255,255,.07)} +.upload-detail-title,.upload-detail-actions{display:flex;align-items:center;gap:7px;min-width:0} +.upload-detail-title strong{max-width:min(48vw,600px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f2f3f6;font-size:var(--saga-text-md)} +.upload-detail-header button{width:38px;height:38px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:#c7ccd4;cursor:pointer} +.upload-detail-header button:hover{background:#191e25;color:#fff} +.upload-detail-header button.active{color:#cfbfff} +.upload-detail-header button:focus-visible{outline:var(--saga-focus-ring);outline-offset:2px} +.upload-detail-back{background:#1b2028!important} +.upload-detail-menu-wrap{position:relative} +.upload-detail-menu{position:absolute;right:0;top:44px;width:166px;padding:6px;border:1px solid rgba(255,255,255,.13);border-radius:10px;background:#181c23;box-shadow:0 15px 40px rgba(0,0,0,.48);z-index:5} +.upload-detail-menu button{width:100%;height:38px!important;display:flex!important;grid-template-columns:none!important;align-items:center;justify-content:flex-start!important;gap:9px!important;padding:0 9px!important} +.upload-detail-menu button.danger{color:#ffb0bd} +.upload-detail-stage{min-height:0;display:flex;align-items:center;justify-content:center;padding:26px;background:#101318;overflow:hidden} +.upload-detail-stage img{max-width:100%;max-height:100%;width:auto;height:auto;display:block;object-fit:contain;border-radius:4px;box-shadow:0 12px 40px rgba(0,0,0,.22)} +.upload-detail-cta{min-height:66px;display:flex;align-items:center;justify-content:center;gap:10px;padding:10px 14px;border-top:1px solid rgba(255,255,255,.07)} +.upload-detail-cta button{min-height:42px;display:flex;align-items:center;justify-content:center;gap:8px;padding:0 18px;border:1px solid rgba(255,255,255,.11);border-radius:9px;background:#272b32;color:#f0f2f5;cursor:pointer;font-size:var(--saga-text-sm)} +.upload-detail-cta button:hover{background:#31363e;border-color:rgba(255,255,255,.18)} +.upload-detail-cta button:focus-visible{outline:var(--saga-focus-ring);outline-offset:2px} +@media(max-width:760px){ + .uploads-desktop-controls{display:none} + .uploads-mobile-controls{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:10px 0 14px} + .uploads-mobile-controls>button,.uploads-mobile-controls>label{position:relative;min-height:52px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;border:0;border-radius:9px;background:transparent;color:#a8afb9;font-size:10px;cursor:pointer} + .uploads-mobile-controls>button:hover,.uploads-mobile-controls>button.active,.uploads-mobile-controls>label:focus-within{background:#141920;color:#fff} + .uploads-mobile-controls label select{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer} + .uploads-mobile-filter-panel{display:flex;margin:-5px 0 12px;padding:9px 10px;border:1px solid rgba(255,255,255,.09);border-radius:11px;background:#0e1319} + .uploads-mobile-filter-panel label{display:flex;align-items:center;gap:8px;color:#d7dbe2;font-size:var(--saga-text-sm)} + .uploads-mobile-filter-panel input{width:17px;height:17px;accent-color:#7a5cff} + .uploads-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;justify-content:stretch} + .uploads-density-comfortable .uploads-grid{grid-template-columns:1fr;gap:12px} + .upload-add-card,.upload-asset-card{aspect-ratio:3/4;border-radius:12px} + .upload-favorite{right:44px} + .upload-favorite,.upload-card-menu-trigger,.upload-selection-mark{top:6px;width:32px;height:32px} + .upload-card-menu-wrap{top:6px;right:6px} + .upload-selection-mark{right:6px} + .upload-card-caption{padding:22px 8px 7px;font-size:10px} + .uploads-manager{left:8px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom));transform:none;display:grid;grid-template-columns:1.25fr repeat(3,minmax(0,1fr)) 38px;gap:3px;padding:7px;border-radius:14px} + .uploads-manager button{height:46px;min-width:0;flex-direction:column;gap:2px;padding:0 4px;font-size:9px} + .uploads-manager button strong{font-size:8px} + .uploads-manager button:last-child{width:38px;padding:0} + .uploads-library:has(.uploads-manager){padding-bottom:88px} + .upload-detail-backdrop{padding:0;background:#0d1015;backdrop-filter:none} + .upload-detail{width:100vw;height:100dvh;border:0;border-radius:0;background:#101318} + .upload-detail-header{min-height:58px;padding:8px 9px} + .upload-detail-title strong{max-width:44vw;font-size:var(--saga-text-sm)} + .upload-detail-header button{width:38px;height:38px} + .upload-detail-rename{display:none!important} + .upload-detail-stage{padding:14px} + .upload-detail-stage img{max-height:calc(100dvh - 160px)} + .upload-detail-cta{min-height:72px;padding:10px;gap:8px} + .upload-detail-cta button{flex:1;min-width:0;padding:0 10px;font-size:12px} +} +@media(max-width:390px){ + .uploads-mobile-controls>button,.uploads-mobile-controls>label{font-size:9px} + .upload-detail-actions{gap:2px} + .upload-detail-title strong{max-width:38vw} + .upload-detail-cta button{font-size:11px} +} diff --git a/apps/studio/visual-baselines/10-gallery-grid.png b/apps/studio/visual-baselines/10-gallery-grid.png index d99d6a6e..ad05b310 100644 Binary files a/apps/studio/visual-baselines/10-gallery-grid.png and b/apps/studio/visual-baselines/10-gallery-grid.png differ diff --git a/apps/studio/visual-baselines/10b-gallery-keyboard-focus.png b/apps/studio/visual-baselines/10b-gallery-keyboard-focus.png index 9fa10c4c..a15a2adb 100644 Binary files a/apps/studio/visual-baselines/10b-gallery-keyboard-focus.png and b/apps/studio/visual-baselines/10b-gallery-keyboard-focus.png differ diff --git a/apps/studio/visual-baselines/14-gallery-mobile-manager.png b/apps/studio/visual-baselines/14-gallery-mobile-manager.png index 08e338c7..b27b0cf5 100644 Binary files a/apps/studio/visual-baselines/14-gallery-mobile-manager.png and b/apps/studio/visual-baselines/14-gallery-mobile-manager.png differ diff --git a/apps/studio/visual-baselines/15-gallery-uploads-desktop.png b/apps/studio/visual-baselines/15-gallery-uploads-desktop.png new file mode 100644 index 00000000..4853cf05 Binary files /dev/null and b/apps/studio/visual-baselines/15-gallery-uploads-desktop.png differ diff --git a/apps/studio/visual-baselines/15b-gallery-upload-detail.png b/apps/studio/visual-baselines/15b-gallery-upload-detail.png new file mode 100644 index 00000000..d073ba20 Binary files /dev/null and b/apps/studio/visual-baselines/15b-gallery-upload-detail.png differ diff --git a/apps/studio/visual-baselines/16-gallery-uploads-mobile.png b/apps/studio/visual-baselines/16-gallery-uploads-mobile.png new file mode 100644 index 00000000..d50d57ae Binary files /dev/null and b/apps/studio/visual-baselines/16-gallery-uploads-mobile.png differ diff --git a/apps/studio/visual-baselines/16b-gallery-uploads-mobile-manager.png b/apps/studio/visual-baselines/16b-gallery-uploads-mobile-manager.png new file mode 100644 index 00000000..58523c04 Binary files /dev/null and b/apps/studio/visual-baselines/16b-gallery-uploads-mobile-manager.png differ diff --git a/apps/studio/visual-baselines/manifest.json b/apps/studio/visual-baselines/manifest.json index ff8dc94a..152cda56 100644 --- a/apps/studio/visual-baselines/manifest.json +++ b/apps/studio/visual-baselines/manifest.json @@ -6,13 +6,53 @@ "maxChangedRatio": 0.0015 }, "surfaces": [ - { "file": "01-create-image-centered.png", "maxChangedRatio": 0.001 }, - { "file": "05b-video-output-controls.png", "maxChangedRatio": 0.001 }, - { "file": "05f-video-picker-keyboard-focus.png", "maxChangedRatio": 0.0015 }, - { "file": "05i-video-audio-on.png", "maxChangedRatio": 0.0015 }, - { "file": "09-mobile-create.png", "maxChangedRatio": 0.0015 }, - { "file": "10-gallery-grid.png", "maxChangedRatio": 0.001 }, - { "file": "10b-gallery-keyboard-focus.png", "maxChangedRatio": 0.0015 }, - { "file": "14-gallery-mobile-manager.png", "maxChangedRatio": 0.0015 } + { + "file": "01-create-image-centered.png", + "maxChangedRatio": 0.001 + }, + { + "file": "05b-video-output-controls.png", + "maxChangedRatio": 0.001 + }, + { + "file": "05f-video-picker-keyboard-focus.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "05i-video-audio-on.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "09-mobile-create.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "10-gallery-grid.png", + "maxChangedRatio": 0.001 + }, + { + "file": "10b-gallery-keyboard-focus.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "14-gallery-mobile-manager.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "15-gallery-uploads-desktop.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "15b-gallery-upload-detail.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "16-gallery-uploads-mobile.png", + "maxChangedRatio": 0.0015 + }, + { + "file": "16b-gallery-uploads-mobile-manager.png", + "maxChangedRatio": 0.0015 + } ] } diff --git a/supabase/migrations/20260825062000_add_studio_upload_asset_library.sql b/supabase/migrations/20260825062000_add_studio_upload_asset_library.sql new file mode 100644 index 00000000..bb343812 --- /dev/null +++ b/supabase/migrations/20260825062000_add_studio_upload_asset_library.sql @@ -0,0 +1,31 @@ +create table if not exists public.studio_uploads ( + id uuid primary key default gen_random_uuid(), + r2_key text not null unique, + filename text not null check (char_length(filename) between 1 and 240), + display_name text not null check (char_length(display_name) between 1 and 240), + mime_type text not null check (mime_type in ('image/png','image/jpeg','image/webp')), + size_bytes bigint not null check (size_bytes between 1 and 26214400), + width integer check (width is null or width > 0), + height integer check (height is null or height > 0), + is_favorite boolean not null default false, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint studio_uploads_library_key_check check (r2_key ~ '^uploads/[0-9]{4}/[0-9]{2}/[0-9a-f-]{36}\.(png|jpg|jpeg|webp)$') +); + +create index if not exists studio_uploads_created_at_idx on public.studio_uploads (created_at desc, id desc); +create index if not exists studio_uploads_favorite_created_at_idx on public.studio_uploads (is_favorite, created_at desc); +create index if not exists studio_uploads_display_name_idx on public.studio_uploads (lower(display_name)); + +alter table public.studio_uploads enable row level security; + +drop policy if exists "studio_uploads_demo_select" on public.studio_uploads; +drop policy if exists "studio_uploads_demo_insert" on public.studio_uploads; +drop policy if exists "studio_uploads_demo_update" on public.studio_uploads; +drop policy if exists "studio_uploads_demo_delete" on public.studio_uploads; + +create policy "studio_uploads_demo_select" on public.studio_uploads for select to anon using (true); +create policy "studio_uploads_demo_insert" on public.studio_uploads for insert to anon with check (true); +create policy "studio_uploads_demo_update" on public.studio_uploads for update to anon using (true) with check (true); +create policy "studio_uploads_demo_delete" on public.studio_uploads for delete to anon using (true);