feat: layout hamburger, media browser filters+previews, billing fixes
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width - Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable - Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files - Admin: "Import Existing Media" button → POST /api/admin/import-media-assets - Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path - SSE task logs: task_logs.py core + router, LiveRenderLog component - CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render - render-worker: add trimesh layer to Dockerfile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ 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,
|
||||
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -32,11 +32,10 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
|
||||
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 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(['still', 'turntable'])
|
||||
|
||||
const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still'
|
||||
const isVideoAsset = (type: MediaAssetType) => type === 'turntable'
|
||||
@@ -82,6 +81,22 @@ function AssetCard({
|
||||
alt={asset.asset_type}
|
||||
className="w-full h-40 object-cover bg-gray-50"
|
||||
/>
|
||||
) : isVideoAsset(asset.asset_type) && asset.download_url ? (
|
||||
<video
|
||||
src={asset.download_url}
|
||||
poster={asset.thumbnail_url ?? undefined}
|
||||
className="w-full h-40 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 }}
|
||||
/>
|
||||
) : asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.asset_type}
|
||||
className="w-full h-40 object-cover bg-gray-50 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-40 flex items-center justify-center bg-gray-50">
|
||||
<TypeIcon type={asset.asset_type} />
|
||||
@@ -169,13 +184,23 @@ export default function MediaBrowserPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||||
const [assetType, setAssetType] = useState<MediaAssetType | ''>('')
|
||||
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 toggleType = (t: MediaAssetType) => {
|
||||
setActiveTypes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(t) ? next.delete(t) : next.add(t)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const filter: MediaFilter = {
|
||||
asset_type: assetType || undefined,
|
||||
asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
|
||||
product_id: productIdInput.trim() || undefined,
|
||||
skip: page * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
@@ -266,29 +291,59 @@ export default function MediaBrowserPage() {
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</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-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-content-muted">{selectedIds.size} selected</span>
|
||||
<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-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user