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.""" """MediaAsset router — /api/media."""
import io import io
import math
import uuid import uuid
import zipfile import zipfile
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.domains.auth.models import User from app.domains.auth.models import User
from app.domains.media.models import MediaAsset, MediaAssetType 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.domains.media import service
from app.utils.auth import get_current_user from app.utils.auth import get_current_user
@@ -98,6 +99,109 @@ async def list_assets(
return 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) @router.get("/{asset_id}", response_model=MediaAssetOut)
async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
asset = await service.get_media_asset(db, asset_id) asset = await service.get_media_asset(db, asset_id)
+28
View File
@@ -25,3 +25,31 @@ class MediaAssetOut(BaseModel):
thumbnail_url: str | None = None thumbnail_url: str | None = None
model_config = {"from_attributes": True} 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' | 'gltf_production'
| 'blend_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 { export interface MediaAsset {
id: string id: string
tenant_id: string | null tenant_id: string | null
@@ -42,7 +89,7 @@ export interface MediaFilter {
sort_dir?: 'asc' | 'desc' sort_dir?: 'asc' | 'desc'
} }
export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]> => { export const listMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]> => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters.product_id) params.set('product_id', filters.product_id) 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.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 { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react' import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getMediaAssets } from '../../api/media' import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad' import { generateGltfGeometry } from '../../api/cad'
import { useAuthStore } from '../../store/auth' 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 { useQuery, useMutation } from '@tanstack/react-query'
import { Box, Loader2, X } from 'lucide-react' import { Box, Loader2, X } from 'lucide-react'
import ThreeDViewer from '../components/cad/ThreeDViewer' import ThreeDViewer from '../components/cad/ThreeDViewer'
import { getMediaAssets } from '../api/media' import { listMediaAssets as getMediaAssets } from '../api/media'
import { generateGltfGeometry } from '../api/cad' import { generateGltfGeometry } from '../api/cad'
/** /**
+266 -496
View File
@@ -1,67 +1,27 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers, Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp, Trash2, ArrowUpDown, ChevronLeft, ChevronRight, Download, Loader2,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import { import {
getMediaAssets, zipDownloadAssets, archiveMediaAsset, deleteMediaAssetPermanent, getMediaAssets,
} from '../api/media' } from '../api/media'
import type { MediaAsset, MediaAssetType, MediaFilter } from '../api/media' import type { MediaAssetItem, MediaAssetType } 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
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── 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) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
const formatDate = (iso: string) => const TYPE_COLORS: Partial<Record<MediaAssetType, 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', thumbnail: 'bg-gray-100 text-gray-700',
still: 'bg-blue-100 text-blue-700', still: 'bg-blue-100 text-blue-700',
turntable: 'bg-purple-100 text-purple-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', blend_production: 'bg-pink-100 text-pink-700',
} }
const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail'] const ASSET_TYPES = [
const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high'] { value: '', label: 'All types' },
const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES] { value: 'still', label: 'Still' },
const DEFAULT_TYPES: Set<MediaAssetType> = new Set(['thumbnail', 'still', 'turntable']) { 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) => const CATEGORIES = [
type === 'thumbnail' || type === 'still' || (mime?.startsWith('image/') ?? false) { value: '', label: 'All categories' },
const isVideoAsset = (type: MediaAssetType, mime?: string | null) => { value: 'TRB', label: 'TRB' },
type === 'turntable' && (mime?.startsWith('video/') ?? true) { 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 }) { const PAGE_SIZE_OPTIONS = [25, 50, 100]
if (isImageAsset(type, mime)) return <Image size={32} className="text-gray-400" />
if (isVideoAsset(type, mime)) return <Film size={32} className="text-gray-400" /> // ── TypeIcon ──────────────────────────────────────────────────────────────────
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" /> function TypeIcon({ type }: { type: MediaAssetType }) {
return <Layers size={32} className="text-gray-400" /> 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({ function AssetCard({ asset }: { asset: MediaAssetItem }) {
asset, const isImage = asset.asset_type === 'still' || asset.asset_type === 'thumbnail'
selected, const isVideo = asset.asset_type === 'turntable'
onToggle, const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'bg-gray-100 text-gray-700'
}: { const sizeStr = formatBytes(asset.file_size_bytes)
asset: MediaAsset
selected: boolean
onToggle: () => void
}) {
const isImg = isImageAsset(asset.asset_type, asset.mime_type)
const authImgUrl = useAuthBlob(asset.download_url, isImg)
const showImage = isImg && !!authImgUrl const handleDownload = () => {
const showImgLoading = isImg && !authImgUrl && !!asset.download_url if (asset.download_url) window.open(asset.download_url, '_blank')
const showThumb = !isImg && !isVideoAsset(asset.asset_type, asset.mime_type) && asset.thumbnail_url }
return ( return (
<div <div
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-colors ${ className="rounded-lg border border-border-default overflow-hidden flex flex-col"
selected ? 'border-blue-500' : 'border-border-default hover:border-accent' style={{ backgroundColor: 'var(--color-bg-surface)' }}
}`}
onClick={onToggle}
> >
<input {/* Preview area */}
type="checkbox" <div
checked={selected} className="w-full h-40 flex items-center justify-center overflow-hidden"
onChange={onToggle} style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
onClick={e => e.stopPropagation()} >
className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer" {isImage && asset.thumbnail_url ? (
/> <img
{showImage ? ( src={asset.thumbnail_url}
<img alt={asset.asset_type}
src={authImgUrl!} className="w-full h-full object-contain p-2"
alt={asset.asset_type} />
className="w-full h-44 object-contain p-2" ) : isVideo && asset.thumbnail_url ? (
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }} <img
/> src={asset.thumbnail_url}
) : showImgLoading ? ( alt={asset.asset_type}
<div className="w-full h-44 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}> className="w-full h-full object-cover opacity-80"
<Loader2 size={28} className="text-gray-400 animate-spin" /> />
</div> ) : (
) : isVideoAsset(asset.asset_type, asset.mime_type) && asset.download_url ? ( <TypeIcon type={asset.asset_type} />
<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>
)} )}
<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>
</div> </div>
) )
} }
// ── AssetRow (List) ─────────────────────────────────────────────────────────── // ── useDebounce ───────────────────────────────────────────────────────────────
function AssetRow({ function useDebounce<T>(value: T, delay: number): T {
asset, const [debounced, setDebounced] = useState(value)
selected, const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
onToggle, useEffect(() => {
onArchive, if (timer.current) clearTimeout(timer.current)
onDownload, timer.current = setTimeout(() => setDebounced(value), delay)
}: { return () => { if (timer.current) clearTimeout(timer.current) }
asset: MediaAsset }, [value, delay])
selected: boolean return debounced
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 ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
export default function MediaBrowserPage() { 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 q = useDebounce(searchInput, 300)
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 toggleType = (t: MediaAssetType) => { // Reset to page 1 when any filter changes
setActiveTypes(prev => { useEffect(() => { setPage(1) }, [q, assetType, categoryKey, renderStatus, pageSize])
const next = new Set(prev)
next.has(t) ? next.delete(t) : next.add(t)
return next
})
setPage(0)
}
const filter: MediaFilter = { const { data, isLoading, isFetching } = useQuery({
asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES, queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, page, pageSize }],
product_id: productIdInput.trim() || undefined, queryFn: () => getMediaAssets({
skip: page * PAGE_SIZE, q: q || undefined,
limit: PAGE_SIZE, asset_type: assetType || undefined,
sort_by: sortBy, category_key: categoryKey || undefined,
sort_dir: sortDir, render_status: renderStatus || undefined,
} page,
page_size: pageSize,
const { data: assets = [], isLoading } = useQuery({ }),
queryKey: ['media', filter], placeholderData: prev => prev,
queryFn: () => getMediaAssets(filter),
}) })
const archiveMutation = useMutation({ const items = data?.items ?? []
mutationFn: archiveMediaAsset, const total = data?.total ?? 0
onSuccess: () => { const pages = data?.pages ?? 1
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')
}
}
return ( return (
<div className="p-6 space-y-5"> <div className="flex flex-col h-full">
{/* Header */} {/* Sticky filter bar */}
<div className="flex items-center justify-between"> <div
<div> className="sticky top-0 z-20 px-6 py-4 border-b border-border-default"
<h1 className="text-xl font-semibold text-content">Media Browser</h1> style={{ backgroundColor: 'var(--color-bg-surface)' }}
<p className="text-sm text-content-muted mt-0.5"> >
Browse and manage rendered media assets <div className="flex items-start gap-3 flex-wrap">
</p> {/* Header */}
</div> <div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2"> <h1 className="text-xl font-semibold text-content">Media Browser</h1>
{/* Sort dropdown */} <p className="text-sm text-content-muted mt-0.5">
<div className="flex items-center gap-1 border border-border-default rounded-md px-2 py-1"> Browse and download rendered media assets
<ArrowUpDown size={14} className="text-content-muted shrink-0" /> </p>
<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> </div>
<button
onClick={() => setView('grid')} {/* Search */}
className={`p-2 rounded-md transition-colors ${ <div className="relative">
view === 'grid' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover' <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
}`} <input
title="Grid view" 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} /> {ASSET_TYPES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</button> </select>
<button
onClick={() => setView('list')} {/* Category */}
className={`p-2 rounded-md transition-colors ${ <select
view === 'list' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover' value={categoryKey}
}`} onChange={e => setCategoryKey(e.target.value)}
title="List view" 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} /> {CATEGORIES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</button> </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>
</div> </div>
{/* Filters */} {/* Content */}
<div className="space-y-2"> <div className="flex-1 overflow-y-auto px-6 py-5">
<div className="flex flex-wrap gap-2 items-center"> {isLoading ? (
<div className="relative"> <div className="flex items-center justify-center h-64 text-content-muted gap-3">
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" /> <Loader2 size={24} className="animate-spin" />
<input <span className="text-sm">Loading assets</span>
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"
/>
</div> </div>
{/* Primary type chips */} ) : items.length === 0 ? (
{PRIMARY_TYPES.map(t => ( <div className="flex flex-col items-center justify-center h-64 text-content-muted gap-3">
<button <Image size={48} className="opacity-25" />
key={t} <p className="text-sm font-medium">No media assets found.</p>
onClick={() => toggleType(t)} <p className="text-xs text-center max-w-xs">
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${ Renders will appear here once orders are completed. Try adjusting your filters.
activeTypes.has(t) </p>
? `${TYPE_COLORS[t]} border-transparent` </div>
: 'bg-surface-alt text-content-muted border-border-default hover:bg-surface-hover' ) : (
}`} <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 => (
{t} <AssetCard key={asset.id} asset={asset} />
</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>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Pagination footer */}
{isLoading ? ( {(total > 0) && (
<div className="flex items-center justify-center h-48 text-content-muted text-sm"> <div
Loading assets className="border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
</div> style={{ backgroundColor: 'var(--color-bg-surface)' }}
) : assets.length === 0 ? ( >
<div className="flex flex-col items-center justify-center h-48 text-content-muted"> {/* Page size selector */}
<Image size={40} className="mb-3 opacity-30" /> <div className="flex items-center gap-2 text-sm text-content-muted">
<p className="text-sm">No assets found</p> <span>Per page:</span>
</div> {PAGE_SIZE_OPTIONS.map(size => (
) : view === 'grid' ? ( <button
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> key={size}
{assets.map(asset => ( onClick={() => setPageSize(size)}
<AssetCard className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
key={asset.id} pageSize === size
asset={asset} ? 'bg-accent text-white'
selected={selectedIds.has(asset.id)} : 'border border-border-default hover:bg-surface-hover text-content'
onToggle={() => toggleSelect(asset.id)} }`}
/> >
))} {size}
</div> </button>
) : ( ))}
<div className="bg-surface border border-border-default rounded-lg overflow-hidden"> </div>
<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 */} {/* Page nav */}
{(assets.length === PAGE_SIZE || page > 0) && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-3 justify-center"> <button
<button disabled={page === 1}
disabled={page === 0} onClick={() => setPage(p => Math.max(1, p - 1))}
onClick={() => setPage(p => Math.max(0, p - 1))} className="p-1.5 rounded border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
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} />
<ChevronLeft size={16} /> </button>
</button> <span className="text-sm text-content-muted whitespace-nowrap">
<span className="text-sm text-content-muted">Page {page + 1}</span> Page {page} of {pages}
<button </span>
disabled={assets.length < PAGE_SIZE} <button
onClick={() => setPage(p => p + 1)} disabled={page >= pages}
className="p-2 rounded-md border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 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> <ChevronRight size={16} />
</div> </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>
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -19,7 +19,7 @@ import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard' import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad' 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' import InlineCadViewer from '../components/cad/InlineCadViewer'
function GlbDownloadButton({ function GlbDownloadButton({