{[['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 &&
+
+
+
+
}
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ );
+}
+
+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 ? (
+ {selected && }
+ ) : (
+ <>
+
+
+
+ {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}
}
+
+
+
+ {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);