feat(phase7.2): media browser with server-side filters + pagination

- Migration 052: indexes on media_assets(asset_type, created_at) and
  products(category_key, lagertyp) for efficient filter queries
- GET /api/media/assets: JOINs media_assets→products→order_lines,
  filters by asset_type / category_key / render_status / q (ILIKE),
  paginated (page/page_size), returns total+pages count
- New schemas: MediaAssetBrowseItem, MediaAssetBrowseResponse
- frontend/src/api/media.ts: getMediaAssets(filters), typed interfaces
- MediaBrowser.tsx: rewritten with sticky filter bar (debounced search,
  type/category/status dropdowns), responsive grid, image previews,
  download buttons, pagination footer with page size selector
- Renamed legacy function to listMediaAssets for backward compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:24:03 +01:00
parent 89c44b846f
commit c99976cc85
8 changed files with 482 additions and 502 deletions
@@ -0,0 +1,31 @@
"""Add indexes for media browser filtering.
Revision ID: 052
Revises: 051
"""
from alembic import op
revision = "052"
down_revision = "051"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Index for filtering media_assets by asset_type + created_at ordering
op.create_index(
"ix_media_assets_asset_type_created",
"media_assets",
["asset_type", "created_at"],
)
# Index for filtering products by category + lagertyp
op.create_index(
"ix_products_category_lagertyp",
"products",
["category_key", "lagertyp"],
)
def downgrade() -> None:
op.drop_index("ix_products_category_lagertyp", table_name="products")
op.drop_index("ix_media_assets_asset_type_created", table_name="media_assets")
+106 -2
View File
@@ -1,17 +1,18 @@
"""MediaAsset router — /api/media."""
import io
import math
import uuid
import zipfile
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.domains.auth.models import User
from app.domains.media.models import MediaAsset, MediaAssetType
from app.domains.media.schemas import MediaAssetOut
from app.domains.media.schemas import MediaAssetOut, MediaAssetBrowseItem, MediaAssetBrowseResponse
from app.domains.media import service
from app.utils.auth import get_current_user
@@ -98,6 +99,109 @@ async def list_assets(
return assets
@router.get("/assets", response_model=MediaAssetBrowseResponse)
async def browse_media_assets(
asset_type: str | None = None,
category_key: str | None = None,
render_status: str | None = None,
q: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> MediaAssetBrowseResponse:
"""Media browser: server-side filtered + paginated asset list with product context."""
from app.domains.products.models import Product
from app.domains.orders.models import OrderLine
from sqlalchemy import desc
# Build query with LEFT JOINs to get product and order_line context.
# MediaAsset has direct product_id FK and order_line_id FK.
# OrderLine has render_status which we also want to surface.
stmt = (
select(
MediaAsset,
Product.name.label("product_name"),
Product.pim_id.label("product_pim_id"),
Product.category_key.label("category_key"),
OrderLine.render_status.label("render_status"),
)
.outerjoin(Product, MediaAsset.product_id == Product.id)
.outerjoin(OrderLine, MediaAsset.order_line_id == OrderLine.id)
.where(MediaAsset.is_archived == False) # noqa: E712
.order_by(desc(MediaAsset.created_at))
)
# Apply filters
if asset_type:
try:
at_enum = MediaAssetType(asset_type)
stmt = stmt.where(MediaAsset.asset_type == at_enum)
except ValueError:
pass # invalid type → ignore filter
if category_key:
stmt = stmt.where(Product.category_key == category_key)
if render_status:
stmt = stmt.where(OrderLine.render_status == render_status)
if q:
pattern = f"%{q}%"
from sqlalchemy import or_
stmt = stmt.where(
or_(
Product.name.ilike(pattern),
Product.pim_id.ilike(pattern),
)
)
# Count total matching rows
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Paginate
offset = (page - 1) * page_size
stmt = stmt.offset(offset).limit(page_size)
rows = await db.execute(stmt)
items: list[MediaAssetBrowseItem] = []
for row in rows.all():
asset: MediaAsset = row[0]
product_name: str | None = row[1]
product_pim_id: str | None = row[2]
cat_key: str | None = row[3]
r_status: str | None = row[4]
item = MediaAssetBrowseItem(
id=asset.id,
asset_type=asset.asset_type,
file_path=asset.storage_key,
file_size_bytes=asset.file_size_bytes,
mime_type=asset.mime_type,
created_at=asset.created_at,
order_line_id=asset.order_line_id,
product_id=asset.product_id,
product_name=product_name,
product_pim_id=product_pim_id,
category_key=cat_key,
render_status=r_status,
download_url=f"/api/media/{asset.id}/download",
thumbnail_url=service.get_thumbnail_url(asset),
)
items.append(item)
pages = max(1, math.ceil(total / page_size))
return MediaAssetBrowseResponse(
items=items,
total=total,
page=page,
page_size=page_size,
pages=pages,
)
@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)
+28
View File
@@ -25,3 +25,31 @@ class MediaAssetOut(BaseModel):
thumbnail_url: str | None = None
model_config = {"from_attributes": True}
class MediaAssetBrowseItem(BaseModel):
"""Enriched asset item for the media browser endpoint."""
id: uuid.UUID
asset_type: MediaAssetType
file_path: str
file_size_bytes: int | None
mime_type: str | None
created_at: datetime
order_line_id: uuid.UUID | None
product_id: uuid.UUID | None
product_name: str | None
product_pim_id: str | None
category_key: str | None
render_status: str | None
download_url: str | None = None
thumbnail_url: str | None = None
model_config = {"from_attributes": True}
class MediaAssetBrowseResponse(BaseModel):
items: list[MediaAssetBrowseItem]
total: int
page: int
page_size: int
pages: int
+48 -1
View File
@@ -10,6 +10,53 @@ export type MediaAssetType =
| 'gltf_production'
| 'blend_production'
// ── Media Browser (server-side filtered + paginated) ──────────────────────────
export interface MediaAssetFilters {
asset_type?: string
category_key?: string
render_status?: string
q?: string
page?: number
page_size?: number
}
export interface MediaAssetItem {
id: string
asset_type: MediaAssetType
file_path: string
file_size_bytes: number | null
mime_type: string | null
created_at: string
order_line_id: string | null
product_id: string | null
product_name: string | null
product_pim_id: string | null
category_key: string | null
render_status: string | null
download_url: string | null
thumbnail_url: string | null
}
export interface MediaAssetListResponse {
items: MediaAssetItem[]
total: number
page: number
page_size: number
pages: number
}
export function getMediaAssets(filters: MediaAssetFilters = {}): Promise<MediaAssetListResponse> {
const params = new URLSearchParams()
if (filters.asset_type) params.set('asset_type', filters.asset_type)
if (filters.category_key) params.set('category_key', filters.category_key)
if (filters.render_status) params.set('render_status', filters.render_status)
if (filters.q) params.set('q', filters.q)
if (filters.page !== undefined) params.set('page', String(filters.page))
if (filters.page_size !== undefined) params.set('page_size', String(filters.page_size))
return api.get(`/media/assets?${params}`).then(r => r.data)
}
export interface MediaAsset {
id: string
tenant_id: string | null
@@ -42,7 +89,7 @@ export interface MediaFilter {
sort_dir?: 'asc' | 'desc'
}
export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]> => {
export const listMediaAssets = (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)
@@ -6,7 +6,7 @@ import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
import { toast } from 'sonner'
import { getMediaAssets } from '../../api/media'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
+1 -1
View File
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation } from '@tanstack/react-query'
import { Box, Loader2, X } from 'lucide-react'
import ThreeDViewer from '../components/cad/ThreeDViewer'
import { getMediaAssets } from '../api/media'
import { listMediaAssets as getMediaAssets } from '../api/media'
import { generateGltfGeometry } from '../api/cad'
/**
+266 -496
View File
@@ -1,67 +1,27 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp, Trash2, ArrowUpDown,
Loader2,
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
getMediaAssets, zipDownloadAssets, archiveMediaAsset, deleteMediaAssetPermanent,
getMediaAssets,
} from '../api/media'
import type { MediaAsset, MediaAssetType, MediaFilter } from '../api/media'
import { useAuthStore } from '../store/auth'
// ── useAuthBlob ───────────────────────────────────────────────────────────────
function useAuthBlob(url: string | null | undefined, enabled: boolean): string | null {
const token = useAuthStore(s => s.token)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !url || !token) {
setBlobUrl(null)
return
}
let objectUrl: string | null = null
let cancelled = false
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
})
.then(blob => {
if (cancelled) return
objectUrl = URL.createObjectURL(blob)
setBlobUrl(objectUrl)
})
.catch(() => {
if (!cancelled) setBlobUrl(null)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [url, token, enabled])
return blobUrl
}
import type { MediaAssetItem, MediaAssetType } from '../api/media'
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = (bytes: number) => {
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const formatBytes = (bytes: number | null) => {
if (bytes == null) return null
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> = {
const TYPE_COLORS: Partial<Record<MediaAssetType, string>> = {
thumbnail: 'bg-gray-100 text-gray-700',
still: 'bg-blue-100 text-blue-700',
turntable: 'bg-purple-100 text-purple-700',
@@ -72,493 +32,303 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
blend_production: 'bg-pink-100 text-pink-700',
}
const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail']
const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high']
const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES]
const DEFAULT_TYPES: Set<MediaAssetType> = new Set(['thumbnail', 'still', 'turntable'])
const ASSET_TYPES = [
{ value: '', label: 'All types' },
{ value: 'still', label: 'Still' },
{ value: 'turntable', label: 'Turntable' },
{ value: 'thumbnail', label: 'Thumbnail' },
{ value: 'gltf_geometry', label: 'glTF Geometry' },
{ value: 'gltf_production', label: 'glTF Production' },
{ value: 'blend_production', label: 'Blend Production' },
{ value: 'stl_low', label: 'STL Low' },
{ value: 'stl_high', label: 'STL High' },
]
const isImageAsset = (type: MediaAssetType, mime?: string | null) =>
type === 'thumbnail' || type === 'still' || (mime?.startsWith('image/') ?? false)
const isVideoAsset = (type: MediaAssetType, mime?: string | null) =>
type === 'turntable' && (mime?.startsWith('video/') ?? true)
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'TRB', label: 'TRB' },
{ value: 'Kugellager', label: 'Kugellager' },
{ value: 'CRB', label: 'CRB' },
{ value: 'Gleitlager', label: 'Gleitlager' },
{ value: 'SRB_TORB', label: 'SRB / TORB' },
{ value: 'Linear_schiene', label: 'Linear-Schiene' },
{ value: 'Anschlagplatten', label: 'Anschlagplatten' },
]
// ── TypeIcon ─────────────────────────────────────────────────────────────────
const RENDER_STATUSES = [
{ value: '', label: 'All statuses' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'processing', label: 'Processing' },
{ value: 'pending', label: 'Pending' },
]
function TypeIcon({ type, mime }: { type: MediaAssetType; mime?: string | null }) {
if (isImageAsset(type, mime)) return <Image size={32} className="text-gray-400" />
if (isVideoAsset(type, mime)) 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" />
const PAGE_SIZE_OPTIONS = [25, 50, 100]
// ── TypeIcon ──────────────────────────────────────────────────────────────────
function TypeIcon({ type }: { type: MediaAssetType }) {
if (type === 'still' || type === 'thumbnail') return <Image size={32} className="text-content-muted" />
if (type === 'turntable') return <Film size={32} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-content-muted" />
return <Layers size={32} className="text-content-muted" />
}
// ── AssetCard (Grid) ──────────────────────────────────────────────────────────
// ── AssetCard ─────────────────────────────────────────────────────────────────
function AssetCard({
asset,
selected,
onToggle,
}: {
asset: MediaAsset
selected: boolean
onToggle: () => void
}) {
const isImg = isImageAsset(asset.asset_type, asset.mime_type)
const authImgUrl = useAuthBlob(asset.download_url, isImg)
function AssetCard({ asset }: { asset: MediaAssetItem }) {
const isImage = asset.asset_type === 'still' || asset.asset_type === 'thumbnail'
const isVideo = asset.asset_type === 'turntable'
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'bg-gray-100 text-gray-700'
const sizeStr = formatBytes(asset.file_size_bytes)
const showImage = isImg && !!authImgUrl
const showImgLoading = isImg && !authImgUrl && !!asset.download_url
const showThumb = !isImg && !isVideoAsset(asset.asset_type, asset.mime_type) && asset.thumbnail_url
const handleDownload = () => {
if (asset.download_url) window.open(asset.download_url, '_blank')
}
return (
<div
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-colors ${
selected ? 'border-blue-500' : 'border-border-default hover:border-accent'
}`}
onClick={onToggle}
className="rounded-lg border border-border-default overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<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"
/>
{showImage ? (
<img
src={authImgUrl!}
alt={asset.asset_type}
className="w-full h-44 object-contain p-2"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
/>
) : showImgLoading ? (
<div className="w-full h-44 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<Loader2 size={28} className="text-gray-400 animate-spin" />
</div>
) : isVideoAsset(asset.asset_type, asset.mime_type) && asset.download_url ? (
<video
src={asset.download_url}
poster={asset.thumbnail_url ?? undefined}
className="w-full h-44 object-cover bg-gray-900"
loop
muted
onMouseEnter={e => (e.currentTarget as HTMLVideoElement).play()}
onMouseLeave={e => { (e.currentTarget as HTMLVideoElement).pause(); (e.currentTarget as HTMLVideoElement).currentTime = 0 }}
/>
) : showThumb ? (
<img
src={asset.thumbnail_url!}
alt={asset.asset_type}
className="w-full h-44 object-contain p-2 opacity-80"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
/>
) : (
<div className="w-full h-44 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<TypeIcon type={asset.asset_type} mime={asset.mime_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>
{/* Preview area */}
<div
className="w-full h-40 flex items-center justify-center overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
>
{isImage && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-contain p-2"
/>
) : isVideo && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-cover opacity-80"
/>
) : (
<TypeIcon type={asset.asset_type} />
)}
<p className="text-xs text-gray-400">{formatDate(asset.created_at)}</p>
</div>
{/* Info */}
<div className="p-3 flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge}`}>
{asset.asset_type}
</span>
{asset.download_url && (
<button
onClick={handleDownload}
className="p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title="Download"
>
<Download size={14} />
</button>
)}
</div>
{asset.product_name && (
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
{asset.product_name}
</p>
)}
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1 text-xs text-content-muted">
<span>{formatDate(asset.created_at)}</span>
{sizeStr && <span>· {sizeStr}</span>}
</div>
</div>
</div>
)
}
// ── AssetRow (List) ───────────────────────────────────────────────────────────
// ── useDebounce ───────────────────────────────────────────────────────────────
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>
)
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setDebounced(value), delay)
return () => { if (timer.current) clearTimeout(timer.current) }
}, [value, delay])
return debounced
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
export default function MediaBrowserPage() {
const qc = useQueryClient()
const [searchInput, setSearchInput] = useState('')
const [assetType, setAssetType] = useState('')
const [categoryKey, setCategoryKey] = useState('')
const [renderStatus, setRenderStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
const [view, setView] = useState<'grid' | 'list'>('grid')
const [activeTypes, setActiveTypes] = useState<Set<MediaAssetType>>(new Set(DEFAULT_TYPES))
const [showAdvanced, setShowAdvanced] = useState(false)
const [productIdInput, setProductIdInput] = useState('')
const [page, setPage] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const q = useDebounce(searchInput, 300)
const toggleType = (t: MediaAssetType) => {
setActiveTypes(prev => {
const next = new Set(prev)
next.has(t) ? next.delete(t) : next.add(t)
return next
})
setPage(0)
}
// Reset to page 1 when any filter changes
useEffect(() => { setPage(1) }, [q, assetType, categoryKey, renderStatus, pageSize])
const filter: MediaFilter = {
asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
product_id: productIdInput.trim() || undefined,
skip: page * PAGE_SIZE,
limit: PAGE_SIZE,
sort_by: sortBy,
sort_dir: sortDir,
}
const { data: assets = [], isLoading } = useQuery({
queryKey: ['media', filter],
queryFn: () => getMediaAssets(filter),
const { data, isLoading, isFetching } = useQuery({
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, page, pageSize }],
queryFn: () => getMediaAssets({
q: q || undefined,
asset_type: assetType || undefined,
category_key: categoryKey || undefined,
render_status: renderStatus || undefined,
page,
page_size: pageSize,
}),
placeholderData: prev => prev,
})
const archiveMutation = useMutation({
mutationFn: archiveMediaAsset,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['media'] })
toast.success('Asset archived')
},
onError: () => toast.error('Failed to archive asset'),
})
const deleteMutation = useMutation({
mutationFn: deleteMediaAssetPermanent,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['media'] })
},
onError: () => toast.error('Failed to delete 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 handleDeleteSelected = async () => {
if (!confirm(`Permanently delete ${selectedIds.size} asset(s)? This cannot be undone.`)) return
let deleted = 0
for (const id of selectedIds) {
try {
await deleteMutation.mutateAsync(id)
deleted++
} catch { /* already toasted per item */ }
}
setSelectedIds(new Set())
toast.success(`${deleted} asset(s) permanently deleted`)
}
const handleDownload = (asset: MediaAsset) => {
if (asset.download_url) {
window.open(asset.download_url, '_blank')
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
const pages = data?.pages ?? 1
return (
<div className="p-6 space-y-5">
{/* 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">
{/* Sort dropdown */}
<div className="flex items-center gap-1 border border-border-default rounded-md px-2 py-1">
<ArrowUpDown size={14} className="text-content-muted shrink-0" />
<select
value={`${sortBy}:${sortDir}`}
onChange={e => {
const [by, dir] = e.target.value.split(':')
setSortBy(by)
setSortDir(dir as 'asc' | 'desc')
setPage(0)
}}
className="text-xs text-content bg-transparent focus:outline-none cursor-pointer"
>
<option value="created_at:desc">Newest first</option>
<option value="created_at:asc">Oldest first</option>
<option value="storage_key:asc">Name AZ</option>
<option value="storage_key:desc">Name ZA</option>
<option value="file_size_bytes:desc">Largest first</option>
<option value="file_size_bytes:asc">Smallest first</option>
</select>
<div className="flex flex-col h-full">
{/* Sticky filter bar */}
<div
className="sticky top-0 z-20 px-6 py-4 border-b border-border-default"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<div className="flex items-start gap-3 flex-wrap">
{/* Header */}
<div className="flex-1 min-w-0 mr-2">
<h1 className="text-xl font-semibold text-content">Media Browser</h1>
<p className="text-sm text-content-muted mt-0.5">
Browse and download rendered media assets
</p>
</div>
<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"
{/* Search */}
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input
type="text"
placeholder="Search product name or PIM-ID..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-1 focus:ring-accent w-64"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
/>
</div>
{/* Asset type */}
<select
value={assetType}
onChange={e => setAssetType(e.target.value)}
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<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"
{ASSET_TYPES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{/* Category */}
<select
value={categoryKey}
onChange={e => setCategoryKey(e.target.value)}
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<LayoutList size={18} />
</button>
{CATEGORIES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{/* Render status */}
<select
value={renderStatus}
onChange={e => setRenderStatus(e.target.value)}
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{RENDER_STATUSES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Results count + loading indicator */}
<div className="flex items-center gap-2 mt-2 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
</div>
</div>
{/* Filters */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2 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-1.5 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-56"
/>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{isLoading ? (
<div className="flex items-center justify-center h-64 text-content-muted gap-3">
<Loader2 size={24} className="animate-spin" />
<span className="text-sm">Loading assets</span>
</div>
{/* Primary type chips */}
{PRIMARY_TYPES.map(t => (
<button
key={t}
onClick={() => toggleType(t)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
activeTypes.has(t)
? `${TYPE_COLORS[t]} border-transparent`
: 'bg-surface-alt text-content-muted border-border-default hover:bg-surface-hover'
}`}
>
{t}
</button>
))}
<button
onClick={() => setShowAdvanced(v => !v)}
className="flex items-center gap-1 px-3 py-1 text-xs text-content-secondary border border-border-default rounded-full hover:bg-surface-hover transition-colors"
>
Advanced
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{selectedIds.size > 0 && (
<span className="text-sm text-content-muted ml-1">{selectedIds.size} selected</span>
)}
</div>
{showAdvanced && (
<div className="flex flex-wrap gap-2">
{ADVANCED_TYPES.map(t => (
<button
key={t}
onClick={() => toggleType(t)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
activeTypes.has(t)
? `${TYPE_COLORS[t]} border-transparent`
: 'bg-surface-alt text-content-muted border-border-default hover:bg-surface-hover'
}`}
>
{t}
</button>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-content-muted gap-3">
<Image size={48} className="opacity-25" />
<p className="text-sm font-medium">No media assets found.</p>
<p className="text-xs text-center max-w-xs">
Renders will appear here once orders are completed. Try adjusting your filters.
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{items.map(asset => (
<AssetCard key={asset.id} asset={asset} />
))}
</div>
)}
</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 footer */}
{(total > 0) && (
<div
className="border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Page size selector */}
<div className="flex items-center gap-2 text-sm text-content-muted">
<span>Per page:</span>
{PAGE_SIZE_OPTIONS.map(size => (
<button
key={size}
onClick={() => setPageSize(size)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
pageSize === size
? 'bg-accent text-white'
: 'border border-border-default hover:bg-surface-hover text-content'
}`}
>
{size}
</button>
))}
</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-amber-600 hover:bg-amber-700 px-4 py-2 rounded-lg transition-colors"
>
<Archive size={16} />
Archive
</button>
<button
onClick={handleDeleteSelected}
className="flex items-center gap-2 text-sm bg-red-600 hover:bg-red-700 px-4 py-2 rounded-lg transition-colors"
>
<Trash2 size={16} />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="text-gray-400 hover:text-white transition-colors text-lg leading-none"
title="Clear selection"
>
×
</button>
{/* Page nav */}
<div className="flex items-center gap-2">
<button
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="p-1.5 rounded 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 whitespace-nowrap">
Page {page} of {pages}
</span>
<button
disabled={page >= pages}
onClick={() => setPage(p => Math.min(pages, p + 1))}
className="p-1.5 rounded border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
+1 -1
View File
@@ -19,7 +19,7 @@ import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { getMediaAssets } from '../api/media'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
function GlbDownloadButton({