diff --git a/backend/alembic/versions/052_media_browser_indexes.py b/backend/alembic/versions/052_media_browser_indexes.py new file mode 100644 index 0000000..8db86cd --- /dev/null +++ b/backend/alembic/versions/052_media_browser_indexes.py @@ -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") diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py index 0e8b6b6..8bdf5b5 100644 --- a/backend/app/domains/media/router.py +++ b/backend/app/domains/media/router.py @@ -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) diff --git a/backend/app/domains/media/schemas.py b/backend/app/domains/media/schemas.py index f6e08c5..1dcec7e 100644 --- a/backend/app/domains/media/schemas.py +++ b/backend/app/domains/media/schemas.py @@ -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 diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index 07873da..e3f0f2e 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -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 { + 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 => { +export const listMediaAssets = (filters: MediaFilter = {}): Promise => { const params = new URLSearchParams() if (filters.product_id) params.set('product_id', filters.product_id) if (filters.order_line_id) params.set('order_line_id', filters.order_line_id) diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index 6e3051c..ec658e6 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -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' diff --git a/frontend/src/pages/CadPreview.tsx b/frontend/src/pages/CadPreview.tsx index 44ee262..ccc42dc 100644 --- a/frontend/src/pages/CadPreview.tsx +++ b/frontend/src/pages/CadPreview.tsx @@ -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' /** diff --git a/frontend/src/pages/MediaBrowser.tsx b/frontend/src/pages/MediaBrowser.tsx index c082a35..3dce0b8 100644 --- a/frontend/src/pages/MediaBrowser.tsx +++ b/frontend/src/pages/MediaBrowser.tsx @@ -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(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 = { +const TYPE_COLORS: Partial> = { 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 = { 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 = 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 - if (isVideoAsset(type, mime)) return - if (type === 'stl_low' || type === 'stl_high') return - if (type === 'gltf_geometry' || type === 'gltf_production') return - return +const PAGE_SIZE_OPTIONS = [25, 50, 100] + +// ── TypeIcon ────────────────────────────────────────────────────────────────── + +function TypeIcon({ type }: { type: MediaAssetType }) { + if (type === 'still' || type === 'thumbnail') return + if (type === 'turntable') return + if (type === 'stl_low' || type === 'stl_high') return + if (type === 'gltf_geometry' || type === 'gltf_production') return + return } -// ── 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 (
- e.stopPropagation()} - className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer" - /> - {showImage ? ( - {asset.asset_type} - ) : showImgLoading ? ( -
- -
- ) : isVideoAsset(asset.asset_type, asset.mime_type) && asset.download_url ? ( -