feat(E): add MediaAsset catalog — model, CRUD API, MediaBrowser UI

Migration 040: media_assets table with RLS (tenant_isolation + admin_bypass).
domains/media/: MediaAsset model, schemas, service, router with ZIP-download.
publish_asset Celery task in rendering/tasks.py.
core/storage.py: download_bytes() method for MinIO + LocalStorage.
frontend: MediaBrowser.tsx (grid/list, multi-select, zip-download, pagination) + api/media.ts.
Route /media (AdminRoute) + sidebar link with Image icon for admin+pm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 17:11:17 +01:00
parent 716451ff76
commit c74e118b98
14 changed files with 870 additions and 2 deletions
@@ -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")
+12
View File
@@ -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). If MINIO_URL is not set, falls back to LocalStorage (reads/writes to UPLOAD_DIR).
""" """
import io
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -91,6 +92,12 @@ class MinIOStorage:
ExpiresIn=expires_in, 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 @property
def backend(self) -> str: def backend(self) -> str:
return "minio" return "minio"
@@ -138,6 +145,11 @@ class LocalStorage:
def get_url(self, object_key: str, expires_in: int = 3600) -> str: def get_url(self, object_key: str, expires_in: int = 3600) -> str:
return f"/api/files/{object_key}" 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 @property
def backend(self) -> str: def backend(self) -> str:
return "local" return "local"
+52
View File
@@ -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)
+101
View File
@@ -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}
+26
View File
@@ -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}
+68
View File
@@ -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}"
+38
View File
@@ -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( def _build_ffmpeg_cmd(
frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = "" frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = ""
) -> list: ) -> list:
+2
View File
@@ -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.billing.router import router as pricing_router
from app.domains.tenants.router import router as tenants_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.rendering.workflow_router import router as workflows_router
from app.domains.media.router import router as media_router
@asynccontextmanager @asynccontextmanager
@@ -78,6 +79,7 @@ app.include_router(render_templates_router, prefix="/api")
app.include_router(notifications_router, prefix="/api") app.include_router(notifications_router, prefix="/api")
app.include_router(tenants_router, prefix="/api") app.include_router(tenants_router, prefix="/api")
app.include_router(workflows_router) app.include_router(workflows_router)
app.include_router(media_router)
@app.get("/health") @app.get("/health")
+2 -1
View File
@@ -12,6 +12,7 @@ from app.domains.notifications.models import AuditLog
from app.domains.billing.models import PricingTier from app.domains.billing.models import PricingTier
from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition, WorkflowDefinition, WorkflowRun, WorkflowNodeResult from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition, WorkflowDefinition, WorkflowRun, WorkflowNodeResult
from app.domains.materials.models import Material, MaterialAlias 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) # Also re-export SystemSetting (no domain assigned — stays as-is)
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
@@ -20,5 +21,5 @@ __all__ = [
"Tenant", "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine", "Tenant", "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine",
"AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition", "AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition",
"WorkflowDefinition", "WorkflowRun", "WorkflowNodeResult", "WorkflowDefinition", "WorkflowRun", "WorkflowNodeResult",
"Material", "MaterialAlias", "SystemSetting", "Material", "MaterialAlias", "MediaAsset", "MediaAssetType", "SystemSetting",
] ]
+9
View File
@@ -18,6 +18,7 @@ import NotificationsPage from './pages/Notifications'
import PreferencesPage from './pages/Preferences' import PreferencesPage from './pages/Preferences'
import TenantsPage from './pages/Tenants' import TenantsPage from './pages/Tenants'
import WorkflowEditorPage from './pages/WorkflowEditor' import WorkflowEditorPage from './pages/WorkflowEditor'
import MediaBrowserPage from './pages/MediaBrowser'
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
@@ -82,6 +83,14 @@ export default function App() {
<Route path="notifications" element={<NotificationsPage />} /> <Route path="notifications" element={<NotificationsPage />} />
<Route path="preferences" element={<PreferencesPage />} /> <Route path="preferences" element={<PreferencesPage />} />
<Route path="cad/:id" element={<CadPreviewPage />} /> <Route path="cad/:id" element={<CadPreviewPage />} />
<Route
path="media"
element={
<AdminRoute>
<MediaBrowserPage />
</AdminRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+69
View File
@@ -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<string, unknown> | 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<MediaAsset[]> => {
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<MediaAsset> =>
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<void> =>
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<void> =>
api.delete(`/media/${id}`).then(() => undefined)
+17 -1
View File
@@ -1,5 +1,5 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' 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 { useAuthStore } from '../../store/auth'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -120,6 +120,22 @@ export default function Layout() {
Admin Admin
</NavLink> </NavLink>
)} )}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/media"
className={({ isActive }) =>
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',
)
}
>
<Image size={18} />
Media Browser
</NavLink>
)}
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/workflows" to="/workflows"
+403
View File
@@ -0,0 +1,403 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
ChevronLeft, ChevronRight, Search,
} from 'lucide-react'
import { toast } from 'sonner'
import {
getMediaAssets, zipDownloadAssets, archiveMediaAsset,
} from '../api/media'
import type { MediaAsset, MediaAssetType, MediaFilter } from '../api/media'
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = (bytes: number) => {
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<MediaAssetType, string> = {
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 <Image size={32} className="text-gray-400" />
if (isVideoAsset(type)) return <Film size={32} className="text-gray-400" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-gray-400" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-gray-400" />
return <Layers size={32} className="text-gray-400" />
}
// ── AssetCard (Grid) ──────────────────────────────────────────────────────────
function AssetCard({
asset,
selected,
onToggle,
}: {
asset: MediaAsset
selected: boolean
onToggle: () => void
}) {
return (
<div
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-colors ${
selected ? 'border-blue-500' : 'border-gray-200 hover:border-gray-300'
}`}
onClick={onToggle}
>
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer"
/>
{isImageAsset(asset.asset_type) && asset.download_url ? (
<img
src={asset.download_url}
alt={asset.asset_type}
className="w-full h-40 object-cover bg-gray-50"
/>
) : (
<div className="w-full h-40 flex items-center justify-center bg-gray-50">
<TypeIcon type={asset.asset_type} />
</div>
)}
<div className="p-2 space-y-1">
<span
className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${TYPE_COLORS[asset.asset_type]}`}
>
{asset.asset_type}
</span>
{asset.file_size_bytes != null && (
<p className="text-xs text-gray-500">{formatBytes(asset.file_size_bytes)}</p>
)}
<p className="text-xs text-gray-400">{formatDate(asset.created_at)}</p>
</div>
</div>
)
}
// ── AssetRow (List) ───────────────────────────────────────────────────────────
function AssetRow({
asset,
selected,
onToggle,
onArchive,
onDownload,
}: {
asset: MediaAsset
selected: boolean
onToggle: () => void
onArchive: () => void
onDownload: () => void
}) {
return (
<tr className={`border-b border-gray-100 hover:bg-gray-50 transition-colors ${selected ? 'bg-blue-50' : ''}`}>
<td className="px-4 py-3">
<input type="checkbox" checked={selected} onChange={onToggle} className="w-4 h-4" />
</td>
<td className="px-4 py-3">
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${TYPE_COLORS[asset.asset_type]}`}>
{asset.asset_type}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700 font-mono truncate max-w-xs">
{asset.storage_key}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{asset.file_size_bytes != null ? formatBytes(asset.file_size_bytes) : '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{asset.mime_type ?? '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDate(asset.created_at)}
</td>
<td className="px-4 py-3 flex items-center gap-2">
{asset.download_url && (
<button
onClick={onDownload}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Download"
>
<Download size={15} />
</button>
)}
<button
onClick={onArchive}
className="p-1.5 rounded hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
title="Archive"
>
<Archive size={15} />
</button>
</td>
</tr>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
export default function MediaBrowserPage() {
const qc = useQueryClient()
const [view, setView] = useState<'grid' | 'list'>('grid')
const [assetType, setAssetType] = useState<MediaAssetType | ''>('')
const [productIdInput, setProductIdInput] = useState('')
const [page, setPage] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<div className="p-6 space-y-5 max-w-screen-xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-content">Media Browser</h1>
<p className="text-sm text-content-muted mt-0.5">
Browse and manage rendered media assets
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setView('grid')}
className={`p-2 rounded-md transition-colors ${
view === 'grid' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover'
}`}
title="Grid view"
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md transition-colors ${
view === 'list' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover'
}`}
title="List view"
>
<LayoutList size={18} />
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Filter by product ID..."
value={productIdInput}
onChange={e => { 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"
/>
</div>
<select
value={assetType}
onChange={e => { setAssetType(e.target.value as MediaAssetType | ''); setPage(0) }}
className="px-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="">All types</option>
{ALL_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
{selectedIds.size > 0 && (
<span className="text-sm text-content-muted">{selectedIds.size} selected</span>
)}
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center h-48 text-content-muted text-sm">
Loading assets
</div>
) : assets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-content-muted">
<Image size={40} className="mb-3 opacity-30" />
<p className="text-sm">No assets found</p>
</div>
) : view === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{assets.map(asset => (
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
onToggle={() => toggleSelect(asset.id)}
/>
))}
</div>
) : (
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
<table className="w-full text-left">
<thead className="bg-surface-alt border-b border-border-default">
<tr>
<th className="px-4 py-3">
<input
type="checkbox"
checked={assets.length > 0 && selectedIds.size === assets.length}
onChange={toggleAll}
className="w-4 h-4"
/>
</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Type</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Storage Key</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Size</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">MIME</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Created</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{assets.map(asset => (
<AssetRow
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
onToggle={() => toggleSelect(asset.id)}
onArchive={() => archiveMutation.mutate(asset.id)}
onDownload={() => handleDownload(asset)}
/>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{(assets.length === PAGE_SIZE || page > 0) && (
<div className="flex items-center gap-3 justify-center">
<button
disabled={page === 0}
onClick={() => setPage(p => Math.max(0, p - 1))}
className="p-2 rounded-md border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm text-content-muted">Page {page + 1}</span>
<button
disabled={assets.length < PAGE_SIZE}
onClick={() => setPage(p => p + 1)}
className="p-2 rounded-md border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
)}
{/* Floating Action Bar */}
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-gray-900 text-white rounded-xl shadow-2xl px-6 py-3 flex items-center gap-4">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<button
onClick={handleZipDownload}
className="flex items-center gap-2 text-sm bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition-colors"
>
<Download size={16} />
ZIP download
</button>
<button
onClick={handleArchiveSelected}
className="flex items-center gap-2 text-sm bg-red-600 hover:bg-red-700 px-4 py-2 rounded-lg transition-colors"
>
<Archive size={16} />
Archive
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="text-gray-400 hover:text-white transition-colors text-lg leading-none"
title="Clear selection"
>
×
</button>
</div>
)}
</div>
)
}