diff --git a/backend/alembic/versions/040_media_assets.py b/backend/alembic/versions/040_media_assets.py new file mode 100644 index 0000000..476ecba --- /dev/null +++ b/backend/alembic/versions/040_media_assets.py @@ -0,0 +1,71 @@ +"""Add media_assets table. + +Revision ID: 040 +Revises: 039 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID, JSONB + +revision = '040' +down_revision = '039' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create enum type + op.execute( + "CREATE TYPE media_asset_type AS ENUM (" + "'thumbnail','still','turntable','stl_low','stl_high'," + "'gltf_geometry','gltf_production','blend_production')" + ) + + op.create_table( + 'media_assets', + sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('tenant_id', UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('product_id', UUID(as_uuid=True), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=True), + sa.Column('cad_file_id', UUID(as_uuid=True), sa.ForeignKey('cad_files.id', ondelete='SET NULL'), nullable=True), + sa.Column('order_line_id', UUID(as_uuid=True), sa.ForeignKey('order_lines.id', ondelete='SET NULL'), nullable=True), + sa.Column('workflow_run_id', UUID(as_uuid=True), sa.ForeignKey('workflow_runs.id', ondelete='SET NULL'), nullable=True), + sa.Column( + 'asset_type', + sa.Enum( + 'thumbnail', 'still', 'turntable', + 'stl_low', 'stl_high', + 'gltf_geometry', 'gltf_production', 'blend_production', + name='media_asset_type', + ), + nullable=False, + ), + sa.Column('storage_key', sa.Text, nullable=False), + sa.Column('file_size_bytes', sa.BigInteger, nullable=True), + sa.Column('mime_type', sa.String(100), nullable=True), + sa.Column('width', sa.Integer, nullable=True), + sa.Column('height', sa.Integer, nullable=True), + sa.Column('duration_s', sa.Float, nullable=True), + sa.Column('render_config', JSONB, nullable=True), + sa.Column('is_archived', sa.Boolean, nullable=False, server_default='false'), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.text('NOW()')), + ) + op.create_index('ix_media_assets_product', 'media_assets', ['product_id']) + op.create_index('ix_media_assets_tenant', 'media_assets', ['tenant_id']) + op.create_index('ix_media_assets_order_line', 'media_assets', ['order_line_id']) + op.create_index('ix_media_assets_asset_type', 'media_assets', ['asset_type']) + + # RLS + op.execute("ALTER TABLE media_assets ENABLE ROW LEVEL SECURITY") + op.execute( + """CREATE POLICY tenant_isolation ON media_assets + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)""" + ) + op.execute( + """CREATE POLICY admin_bypass ON media_assets + USING (current_setting('app.current_tenant_id', true) = 'bypass')""" + ) + + +def downgrade(): + op.drop_table('media_assets') + op.execute("DROP TYPE IF EXISTS media_asset_type") diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index f739b9a..432eb5b 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -14,6 +14,7 @@ Environment variables (set in docker-compose.yml): If MINIO_URL is not set, falls back to LocalStorage (reads/writes to UPLOAD_DIR). """ +import io import logging import os from pathlib import Path @@ -91,6 +92,12 @@ class MinIOStorage: ExpiresIn=expires_in, ) + def download_bytes(self, object_key: str) -> bytes: + """Download file from MinIO and return as bytes.""" + buf = io.BytesIO() + self._client.download_fileobj(self._bucket, object_key, buf) + return buf.getvalue() + @property def backend(self) -> str: return "minio" @@ -138,6 +145,11 @@ class LocalStorage: def get_url(self, object_key: str, expires_in: int = 3600) -> str: return f"/api/files/{object_key}" + def download_bytes(self, object_key: str) -> bytes: + """Read file from local storage and return as bytes.""" + path = self._resolve(object_key) + return path.read_bytes() + @property def backend(self) -> str: return "local" diff --git a/backend/app/domains/media/__init__.py b/backend/app/domains/media/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/domains/media/models.py b/backend/app/domains/media/models.py new file mode 100644 index 0000000..c686b29 --- /dev/null +++ b/backend/app/domains/media/models.py @@ -0,0 +1,52 @@ +import uuid +import enum +from datetime import datetime +from sqlalchemy import String, DateTime, Boolean, Text, BigInteger, Float, Integer, ForeignKey +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class MediaAssetType(str, enum.Enum): + thumbnail = "thumbnail" + still = "still" + turntable = "turntable" + stl_low = "stl_low" + stl_high = "stl_high" + gltf_geometry = "gltf_geometry" + gltf_production = "gltf_production" + blend_production = "blend_production" + + +class MediaAsset(Base): + __tablename__ = "media_assets" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True + ) + product_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True + ) + cad_file_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True + ) + order_line_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("order_lines.id", ondelete="SET NULL"), nullable=True, index=True + ) + workflow_run_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workflow_runs.id", ondelete="SET NULL"), nullable=True + ) + asset_type: Mapped[MediaAssetType] = mapped_column( + SAEnum(MediaAssetType, name="media_asset_type"), nullable=False + ) + storage_key: Mapped[str] = mapped_column(Text, nullable=False) + file_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True) + width: Mapped[int | None] = mapped_column(Integer, nullable=True) + height: Mapped[int | None] = mapped_column(Integer, nullable=True) + duration_s: Mapped[float | None] = mapped_column(Float, nullable=True) + render_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py new file mode 100644 index 0000000..6d4c7c8 --- /dev/null +++ b/backend/app/domains/media/router.py @@ -0,0 +1,101 @@ +"""MediaAsset router — /api/media.""" +import io +import uuid +import zipfile + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.domains.media.models import MediaAssetType +from app.domains.media.schemas import MediaAssetOut +from app.domains.media import service + +router = APIRouter(prefix="/api/media", tags=["media"]) + + +@router.get("/", response_model=list[MediaAssetOut]) +async def list_assets( + product_id: uuid.UUID | None = None, + order_line_id: uuid.UUID | None = None, + asset_type: MediaAssetType | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + assets = await service.list_media_assets( + db, + product_id=product_id, + order_line_id=order_line_id, + asset_type=asset_type, + skip=skip, + limit=limit, + ) + for a in assets: + a.download_url = service.get_download_url(a) + return assets + + +@router.get("/{asset_id}", response_model=MediaAssetOut) +async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + asset = await service.get_media_asset(db, asset_id) + if not asset: + raise HTTPException(404, "Asset not found") + asset.download_url = service.get_download_url(asset) + return asset + + +@router.get("/{asset_id}/download") +async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + from fastapi.responses import RedirectResponse + asset = await service.get_media_asset(db, asset_id) + if not asset: + raise HTTPException(404, "Asset not found") + url = service.get_download_url(asset) + if url: + return RedirectResponse(url) + raise HTTPException(404, "File not available") + + +@router.post("/zip") +async def zip_download( + asset_ids: list[uuid.UUID], + db: AsyncSession = Depends(get_db), +): + assets = [] + for aid in asset_ids: + a = await service.get_media_asset(db, aid) + if a: + assets.append(a) + if not assets: + raise HTTPException(404, "No assets found") + + def generate(): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + from app.core.storage import get_storage + storage = get_storage() + for a in assets: + ext = (a.mime_type or "").split("/")[-1] or "bin" + fname = f"{a.asset_type.value}_{a.id}.{ext}" + try: + data = storage.download_bytes(a.storage_key) + zf.writestr(fname, data) + except Exception: + pass + yield buf.getvalue() + + return StreamingResponse( + generate(), + media_type="application/zip", + headers={"Content-Disposition": "attachment; filename=media-export.zip"}, + ) + + +@router.delete("/{asset_id}") +async def archive_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + asset = await service.archive_media_asset(db, asset_id) + if not asset: + raise HTTPException(404, "Asset not found") + return {"ok": True} diff --git a/backend/app/domains/media/schemas.py b/backend/app/domains/media/schemas.py new file mode 100644 index 0000000..ba4e1c3 --- /dev/null +++ b/backend/app/domains/media/schemas.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel +from app.domains.media.models import MediaAssetType + + +class MediaAssetOut(BaseModel): + id: uuid.UUID + tenant_id: uuid.UUID | None + product_id: uuid.UUID | None + cad_file_id: uuid.UUID | None + order_line_id: uuid.UUID | None + workflow_run_id: uuid.UUID | None + asset_type: MediaAssetType + storage_key: str + file_size_bytes: int | None + mime_type: str | None + width: int | None + height: int | None + duration_s: float | None + render_config: dict | None + is_archived: bool + created_at: datetime + download_url: str | None = None + + model_config = {"from_attributes": True} diff --git a/backend/app/domains/media/service.py b/backend/app/domains/media/service.py new file mode 100644 index 0000000..e962f46 --- /dev/null +++ b/backend/app/domains/media/service.py @@ -0,0 +1,68 @@ +"""MediaAsset service.""" +import uuid +from sqlalchemy import select, update as sql_update +from sqlalchemy.ext.asyncio import AsyncSession +from app.domains.media.models import MediaAsset, MediaAssetType + + +async def list_media_assets( + db: AsyncSession, + product_id: uuid.UUID | None = None, + order_line_id: uuid.UUID | None = None, + asset_type: MediaAssetType | None = None, + is_archived: bool | None = False, + skip: int = 0, + limit: int = 50, +) -> list[MediaAsset]: + q = select(MediaAsset).order_by(MediaAsset.created_at.desc()) + if product_id: + q = q.where(MediaAsset.product_id == product_id) + if order_line_id: + q = q.where(MediaAsset.order_line_id == order_line_id) + if asset_type: + q = q.where(MediaAsset.asset_type == asset_type) + if is_archived is not None: + q = q.where(MediaAsset.is_archived == is_archived) + q = q.offset(skip).limit(limit) + result = await db.execute(q) + return list(result.scalars().all()) + + +async def get_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> MediaAsset | None: + result = await db.execute(select(MediaAsset).where(MediaAsset.id == asset_id)) + return result.scalar_one_or_none() + + +async def create_media_asset(db: AsyncSession, **kwargs) -> MediaAsset: + asset = MediaAsset(**kwargs) + db.add(asset) + await db.commit() + await db.refresh(asset) + return asset + + +async def archive_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> MediaAsset | None: + await db.execute( + sql_update(MediaAsset).where(MediaAsset.id == asset_id).values(is_archived=True) + ) + await db.commit() + return await get_media_asset(db, asset_id) + + +async def delete_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> bool: + asset = await get_media_asset(db, asset_id) + if not asset: + return False + await db.delete(asset) + await db.commit() + return True + + +def get_download_url(asset: MediaAsset) -> str | None: + """Get presigned URL from MinIO or local path.""" + try: + from app.core.storage import get_storage + storage = get_storage() + return storage.get_url(asset.storage_key) + except Exception: + return f"/uploads/{asset.storage_key}" diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 25c3fb0..9073cdb 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -220,6 +220,44 @@ def render_turntable_task( } +@celery_app.task( + name="rendering.publish_asset", + queue="step_processing", +) +def publish_asset( + order_line_id: str, + asset_type: str, + storage_key: str, + render_config: dict | None = None, +) -> str | None: + """Create a MediaAsset record after a successful render.""" + import asyncio + + async def _run() -> str | None: + from app.database import AsyncSessionLocal + from app.domains.media.models import MediaAsset, MediaAssetType + from app.domains.orders.models import OrderLine + from sqlalchemy import select + + async with AsyncSessionLocal() as db: + res = await db.execute(select(OrderLine).where(OrderLine.id == order_line_id)) + line = res.scalar_one_or_none() + if not line: + return None + asset = MediaAsset( + tenant_id=getattr(line, "tenant_id", None), + order_line_id=line.id, + asset_type=MediaAssetType(asset_type), + storage_key=storage_key, + render_config=render_config, + ) + db.add(asset) + await db.commit() + return str(asset.id) + + return asyncio.get_event_loop().run_until_complete(_run()) + + def _build_ffmpeg_cmd( frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = "" ) -> list: diff --git a/backend/app/main.py b/backend/app/main.py index 8fc8682..4eb7eb7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from app.domains.notifications.router import router as notifications_router from app.domains.billing.router import router as pricing_router from app.domains.tenants.router import router as tenants_router from app.domains.rendering.workflow_router import router as workflows_router +from app.domains.media.router import router as media_router @asynccontextmanager @@ -78,6 +79,7 @@ app.include_router(render_templates_router, prefix="/api") app.include_router(notifications_router, prefix="/api") app.include_router(tenants_router, prefix="/api") app.include_router(workflows_router) +app.include_router(media_router) @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b57ccb3..c88e2f4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,6 +12,7 @@ from app.domains.notifications.models import AuditLog from app.domains.billing.models import PricingTier from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition, WorkflowDefinition, WorkflowRun, WorkflowNodeResult from app.domains.materials.models import Material, MaterialAlias +from app.domains.media.models import MediaAsset, MediaAssetType # Also re-export SystemSetting (no domain assigned — stays as-is) from app.models.system_setting import SystemSetting @@ -20,5 +21,5 @@ __all__ = [ "Tenant", "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine", "AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition", "WorkflowDefinition", "WorkflowRun", "WorkflowNodeResult", - "Material", "MaterialAlias", "SystemSetting", + "Material", "MaterialAlias", "MediaAsset", "MediaAssetType", "SystemSetting", ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7bbf31c..736f5a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import NotificationsPage from './pages/Notifications' import PreferencesPage from './pages/Preferences' import TenantsPage from './pages/Tenants' import WorkflowEditorPage from './pages/WorkflowEditor' +import MediaBrowserPage from './pages/MediaBrowser' function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token) @@ -82,6 +83,14 @@ export default function App() { } /> } /> } /> + + + + } + /> diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts new file mode 100644 index 0000000..6b4fc86 --- /dev/null +++ b/frontend/src/api/media.ts @@ -0,0 +1,69 @@ +import api from './client' + +export type MediaAssetType = + | 'thumbnail' + | 'still' + | 'turntable' + | 'stl_low' + | 'stl_high' + | 'gltf_geometry' + | 'gltf_production' + | 'blend_production' + +export interface MediaAsset { + id: string + tenant_id: string | null + product_id: string | null + cad_file_id: string | null + order_line_id: string | null + workflow_run_id: string | null + asset_type: MediaAssetType + storage_key: string + file_size_bytes: number | null + mime_type: string | null + width: number | null + height: number | null + duration_s: number | null + render_config: Record | null + is_archived: boolean + created_at: string + download_url: string | null +} + +export interface MediaFilter { + product_id?: string + order_line_id?: string + asset_type?: MediaAssetType + skip?: number + limit?: number +} + +export const getMediaAssets = (filters: MediaFilter = {}): Promise => { + const params = new URLSearchParams() + if (filters.product_id) params.set('product_id', filters.product_id) + if (filters.order_line_id) params.set('order_line_id', filters.order_line_id) + if (filters.asset_type) params.set('asset_type', filters.asset_type) + if (filters.skip !== undefined) params.set('skip', String(filters.skip)) + if (filters.limit !== undefined) params.set('limit', String(filters.limit)) + return api.get(`/media?${params}`).then(r => r.data) +} + +export const getMediaAsset = (id: string): Promise => + api.get(`/media/${id}`).then(r => r.data) + +export const downloadMediaAsset = (id: string): void => { + window.open(`/api/media/${id}/download`, '_blank') +} + +export const zipDownloadAssets = (ids: string[]): Promise => + api.post('/media/zip', ids, { responseType: 'blob' }).then(r => { + const url = URL.createObjectURL(r.data) + const a = document.createElement('a') + a.href = url + a.download = 'media-export.zip' + a.click() + URL.revokeObjectURL(url) + }) + +export const archiveMediaAsset = (id: string): Promise => + api.delete(`/media/${id}`).then(() => undefined) diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 83c5b32..0882f14 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,5 +1,5 @@ import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' -import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch } from 'lucide-react' +import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image } from 'lucide-react' import { useAuthStore } from '../../store/auth' import { clsx } from 'clsx' import { useQuery } from '@tanstack/react-query' @@ -120,6 +120,22 @@ export default function Layout() { Admin )} + {(user?.role === 'admin' || user?.role === 'project_manager') && ( + + clsx( + 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', + isActive + ? 'bg-accent-light text-accent' + : 'text-content-secondary hover:bg-surface-hover', + ) + } + > + + Media Browser + + )} {(user?.role === 'admin' || user?.role === 'project_manager') && ( { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + +const TYPE_COLORS: Record = { + thumbnail: 'bg-gray-100 text-gray-700', + still: 'bg-blue-100 text-blue-700', + turntable: 'bg-purple-100 text-purple-700', + stl_low: 'bg-yellow-100 text-yellow-700', + stl_high: 'bg-orange-100 text-orange-700', + gltf_geometry: 'bg-green-100 text-green-700', + gltf_production: 'bg-emerald-100 text-emerald-700', + blend_production: 'bg-pink-100 text-pink-700', +} + +const ALL_TYPES: MediaAssetType[] = [ + 'thumbnail', 'still', 'turntable', + 'stl_low', 'stl_high', + 'gltf_geometry', 'gltf_production', 'blend_production', +] + +const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still' +const isVideoAsset = (type: MediaAssetType) => type === 'turntable' + +// ── TypeIcon ───────────────────────────────────────────────────────────────── + +function TypeIcon({ type }: { type: MediaAssetType }) { + if (isImageAsset(type)) return + if (isVideoAsset(type)) return + if (type === 'stl_low' || type === 'stl_high') return + if (type === 'gltf_geometry' || type === 'gltf_production') return + return +} + +// ── AssetCard (Grid) ────────────────────────────────────────────────────────── + +function AssetCard({ + asset, + selected, + onToggle, +}: { + asset: MediaAsset + selected: boolean + onToggle: () => void +}) { + return ( +
+ e.stopPropagation()} + className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer" + /> + {isImageAsset(asset.asset_type) && asset.download_url ? ( + {asset.asset_type} + ) : ( +
+ +
+ )} +
+ + {asset.asset_type} + + {asset.file_size_bytes != null && ( +

{formatBytes(asset.file_size_bytes)}

+ )} +

{formatDate(asset.created_at)}

+
+
+ ) +} + +// ── AssetRow (List) ─────────────────────────────────────────────────────────── + +function AssetRow({ + asset, + selected, + onToggle, + onArchive, + onDownload, +}: { + asset: MediaAsset + selected: boolean + onToggle: () => void + onArchive: () => void + onDownload: () => void +}) { + return ( + + + + + + + {asset.asset_type} + + + + {asset.storage_key} + + + {asset.file_size_bytes != null ? formatBytes(asset.file_size_bytes) : '—'} + + + {asset.mime_type ?? '—'} + + + {formatDate(asset.created_at)} + + + {asset.download_url && ( + + )} + + + + ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +export default function MediaBrowserPage() { + const qc = useQueryClient() + + const [view, setView] = useState<'grid' | 'list'>('grid') + const [assetType, setAssetType] = useState('') + const [productIdInput, setProductIdInput] = useState('') + const [page, setPage] = useState(0) + const [selectedIds, setSelectedIds] = useState>(new Set()) + + const filter: MediaFilter = { + asset_type: assetType || undefined, + product_id: productIdInput.trim() || undefined, + skip: page * PAGE_SIZE, + limit: PAGE_SIZE, + } + + const { data: assets = [], isLoading } = useQuery({ + queryKey: ['media', filter], + queryFn: () => getMediaAssets(filter), + }) + + const archiveMutation = useMutation({ + mutationFn: archiveMediaAsset, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['media'] }) + toast.success('Asset archived') + }, + onError: () => toast.error('Failed to archive asset'), + }) + + const toggleSelect = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + const toggleAll = () => { + if (selectedIds.size === assets.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(assets.map(a => a.id))) + } + } + + const handleZipDownload = async () => { + try { + await zipDownloadAssets([...selectedIds]) + toast.success('ZIP download started') + } catch { + toast.error('ZIP download failed') + } + } + + const handleArchiveSelected = async () => { + for (const id of selectedIds) { + await archiveMutation.mutateAsync(id) + } + setSelectedIds(new Set()) + } + + const handleDownload = (asset: MediaAsset) => { + if (asset.download_url) { + window.open(asset.download_url, '_blank') + } + } + + return ( +
+ {/* Header */} +
+
+

Media Browser

+

+ Browse and manage rendered media assets +

+
+
+ + +
+
+ + {/* Filters */} +
+
+ + { setProductIdInput(e.target.value); setPage(0) }} + className="pl-8 pr-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-64" + /> +
+ + {selectedIds.size > 0 && ( + {selectedIds.size} selected + )} +
+ + {/* Content */} + {isLoading ? ( +
+ Loading assets… +
+ ) : assets.length === 0 ? ( +
+ +

No assets found

+
+ ) : view === 'grid' ? ( +
+ {assets.map(asset => ( + toggleSelect(asset.id)} + /> + ))} +
+ ) : ( +
+ + + + + + + + + + + + + + {assets.map(asset => ( + toggleSelect(asset.id)} + onArchive={() => archiveMutation.mutate(asset.id)} + onDownload={() => handleDownload(asset)} + /> + ))} + +
+ 0 && selectedIds.size === assets.length} + onChange={toggleAll} + className="w-4 h-4" + /> + TypeStorage KeySizeMIMECreatedActions
+
+ )} + + {/* Pagination */} + {(assets.length === PAGE_SIZE || page > 0) && ( +
+ + Page {page + 1} + +
+ )} + + {/* Floating Action Bar */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + + +
+ )} +
+ ) +}