feat: GPU rendering + material matching + perf improvements
- GPU: fix Cycles device activation order — set compute_device_type BEFORE engine init, re-set AFTER open_mainfile wipes preferences - GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts - Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys and add prefix fallback in _apply_material_library (blender_render.py) - Material: production GLB now uses get_material_library_path() which checks active AssetLibrary instead of empty legacy system setting - Admin: RenderTemplateTable multi-select output types (M2M frontend) - Admin: MaterialLibraryPanel replaced with link to Asset Libraries - UX: move Toaster to top-left to avoid dispatch button overlap - SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries - Logging: flush=True on all Blender progress prints, stdout reconfigure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -97,3 +97,9 @@ export async function generateGltfProduction(cadFileId: string): Promise<Generat
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** Force-reset a CAD file stuck in 'processing' to 'failed'. */
|
||||
export async function resetStuckProcessing(cadFileId: string): Promise<{ status: string; message: string }> {
|
||||
const res = await api.post(`/cad/${cadFileId}/reset-stuck`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface RenderTemplate {
|
||||
category_key: string | null;
|
||||
output_type_id: string | null;
|
||||
output_type_name: string | null;
|
||||
output_type_ids: string[];
|
||||
output_type_names: string[];
|
||||
blend_file_path: string;
|
||||
original_filename: string;
|
||||
target_collection: string;
|
||||
@@ -39,7 +41,7 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
|
||||
|
||||
export async function updateRenderTemplate(
|
||||
id: string,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_id' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
|
||||
): Promise<RenderTemplate> {
|
||||
const { data } = await api.patch(`/render-templates/${id}`, updates);
|
||||
return data;
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface WorkflowDefinition {
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
type: 'still' | 'turntable' | 'multi_angle' | 'custom'
|
||||
type: 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
|
||||
params: WorkflowParams
|
||||
nodes?: WorkflowNode[]
|
||||
}
|
||||
|
||||
@@ -186,7 +186,8 @@ export default function OutputTypeTable() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left">
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">Name</th>
|
||||
@@ -1025,6 +1026,7 @@ export default function OutputTypeTable() {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!showAdd && (
|
||||
<div className="px-4 py-3">
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function RenderTemplateTable() {
|
||||
setEditDraft({
|
||||
name: t.name,
|
||||
category_key: t.category_key,
|
||||
output_type_id: t.output_type_id,
|
||||
output_type_ids: t.output_type_ids ?? [],
|
||||
target_collection: t.target_collection,
|
||||
material_replace_enabled: t.material_replace_enabled,
|
||||
lighting_only: t.lighting_only,
|
||||
@@ -320,18 +320,39 @@ export default function RenderTemplateTable() {
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<select
|
||||
className={inputCls}
|
||||
value={editDraft.output_type_id ?? t.output_type_id ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, output_type_id: e.target.value || null })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{outputTypes?.map((ot: OutputType) => (
|
||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-0.5 max-h-32 overflow-y-auto">
|
||||
{outputTypes?.map((ot: OutputType) => {
|
||||
const checked = (editDraft.output_type_ids ?? []).includes(ot.id)
|
||||
return (
|
||||
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const current = editDraft.output_type_ids ?? []
|
||||
const next = checked
|
||||
? current.filter((id: string) => id !== ot.id)
|
||||
: [...current, ot.id]
|
||||
setEditDraft({ ...editDraft, output_type_ids: next })
|
||||
}}
|
||||
/>
|
||||
{ot.name}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
t.output_type_name || <span className="text-content-muted">Any</span>
|
||||
t.output_type_names && t.output_type_names.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.output_type_names.map((name, i) => (
|
||||
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-content-muted">Any</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
|
||||
@@ -4,13 +4,14 @@ import { Canvas } from '@react-three/fiber'
|
||||
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun } from 'lucide-react'
|
||||
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { getMediaAssets } from '../../api/media'
|
||||
import { generateGltfGeometry } from '../../api/cad'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
|
||||
type ViewMode = 'solid' | 'wireframe'
|
||||
type GlbSource = 'geometry' | 'production'
|
||||
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
|
||||
|
||||
const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
|
||||
@@ -91,6 +92,7 @@ export default function InlineCadViewer({
|
||||
const [loadingGlb, setLoadingGlb] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('solid')
|
||||
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
|
||||
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
|
||||
|
||||
const { data: gltfAssets } = useQuery({
|
||||
@@ -100,20 +102,35 @@ export default function InlineCadViewer({
|
||||
refetchInterval: generating ? 4_000 : false,
|
||||
})
|
||||
|
||||
const { data: productionAssets } = useQuery({
|
||||
queryKey: ['media-assets', cadFileId, 'gltf_production'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_production'] }),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
|
||||
}, [generating, gltfAssets])
|
||||
|
||||
const latestAsset = gltfAssets?.[0]
|
||||
const downloadUrl = latestAsset?.download_url
|
||||
const hasGeometry = (gltfAssets?.length ?? 0) > 0
|
||||
const hasProduction = (productionAssets?.length ?? 0) > 0
|
||||
|
||||
// Auto-switch to production if it's the only available source
|
||||
useEffect(() => {
|
||||
if (!hasGeometry && hasProduction) setGlbSource('production')
|
||||
}, [hasGeometry, hasProduction])
|
||||
|
||||
const activeDownloadUrl =
|
||||
glbSource === 'production'
|
||||
? productionAssets?.[0]?.download_url
|
||||
: gltfAssets?.[0]?.download_url
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadUrl || !token) return
|
||||
// Clear stale mesh immediately so the loading spinner shows instead of old geometry
|
||||
if (!activeDownloadUrl || !token) return
|
||||
setGlbBlobUrl(null)
|
||||
setLoadingGlb(true)
|
||||
let blobUrl = ''
|
||||
fetch(downloadUrl, { headers: { Authorization: `Bearer ${token}` } })
|
||||
fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
blobUrl = URL.createObjectURL(blob)
|
||||
@@ -124,7 +141,7 @@ export default function InlineCadViewer({
|
||||
return () => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}, [downloadUrl, token])
|
||||
}, [activeDownloadUrl, token])
|
||||
|
||||
const generateMut = useMutation({
|
||||
mutationFn: () => generateGltfGeometry(cadFileId),
|
||||
@@ -149,6 +166,19 @@ export default function InlineCadViewer({
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
|
||||
{/* Geometry / Production toggle — only when both exist */}
|
||||
{hasGeometry && hasProduction && (
|
||||
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
|
||||
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC, no materials)">
|
||||
<Box size={12} /> Geo
|
||||
</ToolbarBtn>
|
||||
<div className="w-px bg-white/10" />
|
||||
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender + PBR materials)">
|
||||
<Cpu size={12} /> PBR
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode */}
|
||||
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
|
||||
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
|
||||
|
||||
@@ -24,13 +24,22 @@ import api from '../../api/client'
|
||||
export interface ThreeDViewerProps {
|
||||
cadFileId: string
|
||||
onClose: () => void
|
||||
/** URL for the geometry-only GLB (from STL export) */
|
||||
/** URL for the geometry-only GLB (from OCC export) */
|
||||
geometryGltfUrl?: string
|
||||
/** URL for the production-quality GLB (from asset library render) */
|
||||
/** URL for the production-quality GLB (Blender + PBR materials) */
|
||||
productionGltfUrl?: string
|
||||
/** Download URLs for GLB and .blend assets */
|
||||
/** Whether a geometry GLB exists (for hint display) */
|
||||
hasGeometryGlb?: boolean
|
||||
/** Whether a production GLB exists (for hint display) */
|
||||
hasProductionGlb?: boolean
|
||||
/** Called when the user clicks "Generate Geometry GLB" from the hint banner */
|
||||
onGenerateGeometry?: () => void
|
||||
/** Whether a geometry GLB generation is in progress */
|
||||
isGeneratingGeometry?: boolean
|
||||
/** Download URLs for assets */
|
||||
downloadUrls?: {
|
||||
glb?: string
|
||||
production?: string
|
||||
blend?: string
|
||||
}
|
||||
}
|
||||
@@ -217,9 +226,15 @@ export default function ThreeDViewer({
|
||||
onClose,
|
||||
geometryGltfUrl,
|
||||
productionGltfUrl,
|
||||
hasGeometryGlb,
|
||||
hasProductionGlb,
|
||||
onGenerateGeometry,
|
||||
isGeneratingGeometry,
|
||||
downloadUrls,
|
||||
}: ThreeDViewerProps) {
|
||||
const [mode, setMode] = useState<ViewMode>('geometry')
|
||||
// Default to production mode if only production GLB is available
|
||||
const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry'
|
||||
const [mode, setMode] = useState<ViewMode>(initialMode)
|
||||
const [wireframe, setWireframe] = useState(false)
|
||||
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
@@ -232,11 +247,11 @@ export default function ThreeDViewer({
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Resolve the active model URL based on mode
|
||||
// Resolve the active model URL: prefer selected mode, fall back to whichever URL exists
|
||||
const activeUrl =
|
||||
mode === 'production' && productionGltfUrl
|
||||
? productionGltfUrl
|
||||
: geometryGltfUrl
|
||||
: geometryGltfUrl ?? productionGltfUrl
|
||||
|
||||
const handleModelReady = useCallback(() => setModelReady(true), [])
|
||||
const handleError = useCallback((msg: string) => setLoadError(msg), [])
|
||||
@@ -312,11 +327,20 @@ export default function ThreeDViewer({
|
||||
{/* Download buttons */}
|
||||
{downloadUrls?.glb && (
|
||||
<button
|
||||
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}.glb`)}
|
||||
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}_geometry.glb`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<Download size={12} />
|
||||
GLB
|
||||
Geometry GLB
|
||||
</button>
|
||||
)}
|
||||
{downloadUrls?.production && (
|
||||
<button
|
||||
onClick={() => handleDownload(downloadUrls.production!, `${cadFileId}_production.glb`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<Download size={12} />
|
||||
Production GLB
|
||||
</button>
|
||||
)}
|
||||
{downloadUrls?.blend && (
|
||||
@@ -350,6 +374,37 @@ export default function ThreeDViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint banners */}
|
||||
{!hasProductionGlb && (
|
||||
<div className="bg-amber-900/60 border-b border-amber-700/50 px-4 py-2 flex items-center gap-2 text-amber-200 text-xs shrink-0">
|
||||
<Cpu size={13} className="shrink-0" />
|
||||
<span>
|
||||
<strong>No Production GLB yet.</strong> Go to the product page and click "Generate Production GLB" to create a high-quality version with PBR materials and proper mesh smoothing.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasGeometryGlb && hasProductionGlb && onGenerateGeometry && (
|
||||
<div className="bg-blue-900/50 border-b border-blue-700/50 px-4 py-2 flex items-center gap-3 text-blue-200 text-xs shrink-0">
|
||||
<Box size={13} className="shrink-0" />
|
||||
<span>
|
||||
<strong>Showing Production GLB.</strong> Generate a Geometry GLB to enable the mode toggle and compare geometry vs. production quality.
|
||||
</span>
|
||||
{isGeneratingGeometry ? (
|
||||
<span className="flex items-center gap-1 text-blue-300 ml-auto shrink-0">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Generating…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onGenerateGeometry}
|
||||
className="ml-auto shrink-0 px-3 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Generate Geometry GLB
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Viewport */}
|
||||
<div className="relative flex-1">
|
||||
{loadError && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Settings2, BarChart2, Activity, ImageIcon, DollarSign, Cpu,
|
||||
@@ -123,8 +123,22 @@ function TimeframeSelector({ widgets }: { widgets: WidgetType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useLargeScreen() {
|
||||
const [isLarge, setIsLarge] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.innerWidth >= 1024 : true
|
||||
)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1024px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsLarge(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
return isLarge
|
||||
}
|
||||
|
||||
function DashboardGridInner() {
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
const isLarge = useLargeScreen()
|
||||
|
||||
const { data: widgets, isLoading } = useQuery({
|
||||
queryKey: ['dashboard-config'],
|
||||
@@ -150,7 +164,7 @@ function DashboardGridInner() {
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-40 rounded-xl animate-pulse bg-surface-muted" />
|
||||
))}
|
||||
@@ -162,7 +176,7 @@ function DashboardGridInner() {
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}
|
||||
style={isLarge ? { gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' } : { gridTemplateColumns: '1fr' }}
|
||||
>
|
||||
{(widgets ?? []).map((w, i) => {
|
||||
const pos = w.position
|
||||
@@ -173,12 +187,12 @@ function DashboardGridInner() {
|
||||
return (
|
||||
<div
|
||||
key={`${w.widget_type}-${i}`}
|
||||
style={{
|
||||
style={isLarge ? {
|
||||
gridColumnStart: pos.col + 1,
|
||||
gridColumnEnd: `span ${pos.w}`,
|
||||
gridRowStart: pos.row + 1,
|
||||
gridRowEnd: `span ${pos.h}`,
|
||||
}}
|
||||
} : {}}
|
||||
>
|
||||
<WidgetContainer title={meta.title} icon={meta.icon}>
|
||||
<WidgetBody type={w.widget_type as WidgetType} />
|
||||
|
||||
@@ -54,7 +54,7 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors theme={resolvedTheme} />
|
||||
<Toaster position="top-left" richColors theme={resolvedTheme} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+125
-66
@@ -9,8 +9,6 @@ import PricingTierTable from '../components/admin/PricingTierTable'
|
||||
import OutputTypeTable from '../components/admin/OutputTypeTable'
|
||||
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { getMaterialLibraryInfo, uploadMaterialLibrary, deleteMaterialLibrary } from '../api/renderTemplates'
|
||||
import type { MaterialLibraryInfo } from '../api/renderTemplates'
|
||||
import { listPricingTiers } from '../api/pricing'
|
||||
import { listOutputTypes } from '../api/outputTypes'
|
||||
import {
|
||||
@@ -92,6 +90,10 @@ export default function AdminPage() {
|
||||
gltf_material_quality: string
|
||||
gltf_pbr_roughness: number
|
||||
gltf_pbr_metallic: number
|
||||
gltf_preview_linear_deflection: number
|
||||
gltf_preview_angular_deflection: number
|
||||
gltf_production_linear_deflection: number
|
||||
gltf_production_angular_deflection: number
|
||||
}
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
@@ -115,6 +117,9 @@ export default function AdminPage() {
|
||||
const [viewerDraft, setViewerDraft] = useState<Partial<Settings>>({})
|
||||
const viewer3d = { ...settings, ...viewerDraft } as Settings
|
||||
|
||||
const [tessellationDraft, setTessellationDraft] = useState<Partial<Settings>>({})
|
||||
const tess = { ...settings, ...tessellationDraft } as Settings
|
||||
|
||||
const { data: rendererStatus, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['renderer-status'],
|
||||
queryFn: async () => {
|
||||
@@ -166,6 +171,14 @@ export default function AdminPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const recoverStuckMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.data.message || 'Stuck files recovered')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const seedWorkflowsMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/settings/seed-workflows'),
|
||||
onSuccess: (res) => {
|
||||
@@ -636,6 +649,18 @@ export default function AdminPage() {
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => recoverStuckMut.mutate()}
|
||||
disabled={recoverStuckMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start border-amber-400/40 text-amber-600 hover:bg-amber-50"
|
||||
title="Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'. Runs automatically every 5 min."
|
||||
>
|
||||
<RefreshCw size={14} className={recoverStuckMut.isPending ? 'animate-spin' : ''} />
|
||||
{recoverStuckMut.isPending ? 'Recovering…' : 'Recover Stuck Processing'}
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => processUnprocessedMut.mutate()}
|
||||
@@ -1091,6 +1116,94 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Tessellation Quality */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-default">
|
||||
<h2 className="font-semibold text-content">Tessellation Quality</h2>
|
||||
<p className="text-sm text-content-muted mt-0.5">
|
||||
OCC mesh precision for GLB export. Lower values = finer mesh + larger files + slower export.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.001"
|
||||
max="10"
|
||||
value={tess.gltf_preview_linear_deflection ?? 0.1}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_linear_deflection: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-content-muted">mm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0.05"
|
||||
max="1.5"
|
||||
value={tess.gltf_preview_angular_deflection ?? 0.5}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-content-muted">rad</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">Used when clicking "Generate Geometry GLB".</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Production (Production GLB)</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.005"
|
||||
min="0.001"
|
||||
max="10"
|
||||
value={tess.gltf_production_linear_deflection ?? 0.03}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_linear_deflection: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-content-muted">mm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0.05"
|
||||
max="1.5"
|
||||
value={tess.gltf_production_angular_deflection ?? 0.2}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-content-muted">rad</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">Used when clicking "Generate Production GLB". Smaller = smoother surfaces.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { updateSettingsMut.mutate(tessellationDraft); setTessellationDraft({}) }}
|
||||
disabled={Object.keys(tessellationDraft).length === 0 || updateSettingsMut.isPending}
|
||||
className="btn-primary disabled:opacity-40"
|
||||
>
|
||||
Save Tessellation Settings
|
||||
</button>
|
||||
{Object.keys(tessellationDraft).length > 0 && (
|
||||
<button onClick={() => setTessellationDraft({})} className="btn-secondary">Reset</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Material Library link */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -1111,73 +1224,19 @@ export default function AdminPage() {
|
||||
|
||||
|
||||
function MaterialLibraryPanel() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: info } = useQuery({
|
||||
queryKey: ['material-library-info'],
|
||||
queryFn: getMaterialLibraryInfo,
|
||||
})
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: (file: File) => uploadMaterialLibrary(file),
|
||||
onSuccess: () => {
|
||||
toast.success('Material library uploaded')
|
||||
qc.invalidateQueries({ queryKey: ['material-library-info'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteMaterialLibrary,
|
||||
onSuccess: () => {
|
||||
toast.success('Material library removed')
|
||||
qc.invalidateQueries({ queryKey: ['material-library-info'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||
})
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadMut.mutate(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-content-secondary">Material Library (.blend)</h4>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-content-secondary">Material Library</h4>
|
||||
<p className="text-xs text-content-muted">
|
||||
Materials in this file can be assigned to product parts when "Material Replace" is enabled on a template.
|
||||
Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.
|
||||
</p>
|
||||
|
||||
{info?.exists ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border-default bg-status-success-bg">
|
||||
<CheckCircle2 size={16} className="text-green-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content">{info.filename}</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
{info.size_bytes ? `${(info.size_bytes / 1024 / 1024).toFixed(1)} MB` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 px-3 py-1.5 text-sm border border-border-default rounded-md bg-surface text-content-secondary hover:bg-surface-hover cursor-pointer">
|
||||
<Upload size={14} /> Replace
|
||||
<input type="file" accept=".blend" className="hidden" onChange={handleFileChange} />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove material library?')) deleteMut.mutate() }}
|
||||
disabled={deleteMut.isPending}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 rounded"
|
||||
title="Remove library"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 px-4 py-3 border-2 border-dashed border-border-default rounded-lg text-sm text-content-muted hover:border-blue-400 hover:text-blue-600 cursor-pointer transition-colors">
|
||||
<Upload size={16} />
|
||||
{uploadMut.isPending ? 'Uploading...' : 'Click to upload material library .blend file'}
|
||||
<input type="file" accept=".blend" className="hidden" onChange={handleFileChange} />
|
||||
</label>
|
||||
)}
|
||||
<Link
|
||||
to="/asset-libraries"
|
||||
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-hover"
|
||||
>
|
||||
<Layers size={14} />
|
||||
Manage Asset Libraries
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function CadPreviewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// No GLB available yet — show generate prompt
|
||||
if (!latestGltf) {
|
||||
// No GLB at all — show generate prompt
|
||||
if (!latestGltf && !latestProduction) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
|
||||
@@ -130,8 +130,13 @@ export default function CadPreviewPage() {
|
||||
onClose={() => navigate(-1)}
|
||||
geometryGltfUrl={latestGltf?.download_url ?? undefined}
|
||||
productionGltfUrl={latestProduction?.download_url ?? undefined}
|
||||
hasGeometryGlb={!!latestGltf}
|
||||
hasProductionGlb={!!latestProduction}
|
||||
isGeneratingGeometry={generating}
|
||||
onGenerateGeometry={() => generateMutation.mutate()}
|
||||
downloadUrls={{
|
||||
glb: latestGltf?.download_url ?? undefined,
|
||||
production: latestProduction?.download_url ?? undefined,
|
||||
blend: latestBlend?.download_url ?? undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -739,6 +739,10 @@ function OrderLineRow({
|
||||
alt={line.product.name || ''}
|
||||
className="w-10 h-10 object-contain rounded border bg-surface"
|
||||
/>
|
||||
) : (line.render_status === 'processing' || line.render_status === 'pending') ? (
|
||||
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center animate-pulse">
|
||||
<Loader2 size={16} className="text-accent animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center">
|
||||
<Box size={16} className="text-content-muted" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import {
|
||||
ArrowLeft, Pencil, Save, X, Box, Image,
|
||||
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler,
|
||||
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -18,9 +18,86 @@ import { listMaterials } from '../api/materials'
|
||||
import MaterialInput from '../components/shared/MaterialInput'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { generateGltfGeometry, generateGltfProduction } from '../api/cad'
|
||||
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
|
||||
import { getMediaAssets } from '../api/media'
|
||||
import InlineCadViewer from '../components/cad/InlineCadViewer'
|
||||
|
||||
function GlbDownloadButton({
|
||||
label, url, filename, onGenerate, isGenerating, title,
|
||||
}: {
|
||||
label: string
|
||||
url: string | null
|
||||
filename: string
|
||||
onGenerate: () => void
|
||||
isGenerating: boolean
|
||||
title: string
|
||||
}) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!url || !token) return
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
} catch {
|
||||
toast.error(`Failed to download ${label}`)
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<div className="flex gap-1 w-full">
|
||||
<button
|
||||
className="btn-secondary text-xs flex-1 justify-start"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
title={title}
|
||||
>
|
||||
{isDownloading
|
||||
? <><Loader2 size={12} className="animate-spin" />Downloading…</>
|
||||
: <><Download size={12} />Download {label}</>}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs px-2 shrink-0"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating}
|
||||
title={`Re-generate ${label}`}
|
||||
>
|
||||
{isGenerating
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
: <RotateCcw size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating}
|
||||
title={title}
|
||||
>
|
||||
{isGenerating
|
||||
? <><Loader2 size={12} className="animate-spin" />Queuing…</>
|
||||
: <><Download size={12} />Generate {label}</>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CadStatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">No STEP</span>
|
||||
@@ -92,6 +169,25 @@ export default function ProductDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [product?.id, product?.cad_parsed_objects?.length, product?.cad_part_materials.length])
|
||||
|
||||
const cadFileId = product?.cad_file_id ?? null
|
||||
|
||||
const { data: geometryGlbAssets = [] } = useQuery({
|
||||
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_geometry'] }),
|
||||
enabled: !!cadFileId,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const { data: productionGlbAssets = [] } = useQuery({
|
||||
queryKey: ['media-assets', cadFileId, 'gltf_production'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
|
||||
enabled: !!cadFileId,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
|
||||
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
|
||||
|
||||
const { data: renders = [] } = useQuery<ProductRender[]>({
|
||||
queryKey: ['product-renders', id],
|
||||
queryFn: () => getProductRenders(id!),
|
||||
@@ -234,6 +330,33 @@ export default function ProductDetailPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const generateGeometryGlbMut = useMutation({
|
||||
mutationFn: () => generateGltfGeometry(product!.cad_file_id!),
|
||||
onSuccess: () => {
|
||||
toast.info('Geometry GLB export queued')
|
||||
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_geometry'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
|
||||
})
|
||||
|
||||
const generateProductionGlbMut = useMutation({
|
||||
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
|
||||
onSuccess: () => {
|
||||
toast.info('Production GLB export queued')
|
||||
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
|
||||
})
|
||||
|
||||
const resetStuckMut = useMutation({
|
||||
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
qc.invalidateQueries({ queryKey: ['product', id] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reset failed'),
|
||||
})
|
||||
|
||||
const reprocessMut = useMutation({
|
||||
mutationFn: () => reprocessProduct(id!),
|
||||
onSuccess: () => {
|
||||
@@ -545,6 +668,17 @@ export default function ProductDetailPage() {
|
||||
{isPrivileged && (
|
||||
<>
|
||||
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||
{product.processing_status === 'processing' && (
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start border-amber-400/40 text-amber-700 hover:bg-amber-50"
|
||||
onClick={() => resetStuckMut.mutate()}
|
||||
disabled={resetStuckMut.isPending}
|
||||
title="Force-reset a CAD file stuck in 'processing'. Use if the spinner never goes away."
|
||||
>
|
||||
<Loader2 size={12} className={resetStuckMut.isPending ? 'animate-spin' : ''} />
|
||||
{resetStuckMut.isPending ? 'Resetting…' : 'Reset Stuck Processing'}
|
||||
</button>
|
||||
)}
|
||||
<div {...getRootProps()} className="cursor-pointer">
|
||||
<input {...getInputProps()} />
|
||||
<button className="btn-secondary text-xs w-full justify-start" disabled={cadUploadMut.isPending}>
|
||||
@@ -573,30 +707,22 @@ export default function ProductDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() =>
|
||||
generateGltfGeometry(product.cad_file_id!)
|
||||
.then(() => toast.info('Geometry GLB export queued'))
|
||||
.catch(() => toast.error('Failed to queue GLB export'))
|
||||
}
|
||||
<GlbDownloadButton
|
||||
label="Geometry GLB"
|
||||
url={geometryGlbUrl}
|
||||
filename={`${product.name ?? product.pim_id}_geometry.glb`}
|
||||
onGenerate={() => generateGeometryGlbMut.mutate()}
|
||||
isGenerating={generateGeometryGlbMut.isPending}
|
||||
title="Export geometry GLB directly from STEP via OCC (no Blender)"
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate Geometry GLB
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() =>
|
||||
generateGltfProduction(product.cad_file_id!)
|
||||
.then(() => toast.info('Production GLB export queued'))
|
||||
.catch(() => toast.error('Failed to queue production GLB export'))
|
||||
}
|
||||
/>
|
||||
<GlbDownloadButton
|
||||
label="Production GLB"
|
||||
url={productionGlbUrl}
|
||||
filename={`${product.name ?? product.pim_id}_production.glb`}
|
||||
onGenerate={() => generateProductionGlbMut.mutate()}
|
||||
isGenerating={generateProductionGlbMut.isPending}
|
||||
title="Export production GLB with PBR materials via Blender"
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate Production GLB
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -389,10 +389,11 @@ function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProp
|
||||
<label className="block text-sm text-content-secondary mb-1">Typ</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ value: 'still', label: 'Still', desc: 'Einzelbild PNG' },
|
||||
{ value: 'turntable', label: 'Turntable', desc: 'Animations-MP4' },
|
||||
{ value: 'multi_angle', label: 'Multi-Angle', desc: 'Mehrere Winkel' },
|
||||
{ value: 'custom', label: 'Custom', desc: 'Freier Editor' },
|
||||
{ value: 'still', label: 'Still', desc: 'Single PNG image' },
|
||||
{ value: 'turntable', label: 'Turntable', desc: 'Animation MP4' },
|
||||
{ value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' },
|
||||
{ value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' },
|
||||
{ value: 'custom', label: 'Custom', desc: 'Free canvas' },
|
||||
] as { value: WorkflowConfig['type']; label: string; desc: string }[]).map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -650,6 +651,7 @@ export default function WorkflowEditor() {
|
||||
still: 'Still',
|
||||
turntable: 'Turntable',
|
||||
multi_angle: 'Multi-Angle',
|
||||
still_with_exports: 'Still + GLB',
|
||||
custom: 'Custom',
|
||||
}
|
||||
|
||||
@@ -657,6 +659,7 @@ export default function WorkflowEditor() {
|
||||
still: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
turntable: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
multi_angle: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
still_with_exports: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
custom: 'bg-surface-hover text-content-muted',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user