import { useState, useEffect, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react'; import { ModelViewer } from './ModelViewer'; import { GcodeViewer } from './GcodeViewer'; import { Button } from './Button'; import { api } from '../api/client'; import { openInSlicer, type SlicerType } from '../utils/slicer'; import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates'; type ViewTab = '3d' | 'gcode'; interface ModelViewerModalProps { archiveId?: number; libraryFileId?: number; title: string; fileType?: string; onClose: () => void; } interface Capabilities { has_model: boolean; has_gcode: boolean; has_source: boolean; build_volume: { x: number; y: number; z: number }; filament_colors: string[]; } export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) { const { t } = useTranslation(); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings }); const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio'; const isLibrary = libraryFileId != null; const [activeTab, setActiveTab] = useState(null); const [capabilities, setCapabilities] = useState(null); const [loading, setLoading] = useState(true); const [platesData, setPlatesData] = useState(null); const [platesLoading, setPlatesLoading] = useState(false); const [selectedPlateId, setSelectedPlateId] = useState(null); const [platePage, setPlatePage] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); const [platePanelHeight, setPlatePanelHeight] = useState(null); const [isDraggingDivider, setIsDraggingDivider] = useState(false); const [hasCustomSplit, setHasCustomSplit] = useState(false); const splitContainerRef = useRef(null); const platesPanelRef = useRef(null); const dividerHeight = 10; const minPlateHeight = 160; const minViewerPx = 240; const minViewerRatio = 0.35; // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); useEffect(() => { setLoading(true); if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); const hasModel = normalizedType === '3mf' || normalizedType === 'stl'; const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf'; setCapabilities({ has_model: hasModel, has_gcode: hasGcode, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [], }); setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null); setLoading(false); return; } if (!archiveId) { setCapabilities(null); setActiveTab(null); setLoading(false); return; } api.getArchiveCapabilities(archiveId) .then(caps => { setCapabilities(caps); // Auto-select the first available tab if (caps.has_model) { setActiveTab('3d'); } else if (caps.has_gcode) { setActiveTab('gcode'); } setLoading(false); }) .catch(() => { // Fallback to 3D model tab if capabilities check fails setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] }); setActiveTab('3d'); setLoading(false); }); }, [archiveId, fileType, isLibrary]); useEffect(() => { setPlatesLoading(true); setSelectedPlateId(null); setPlatePage(0); if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); if (!libraryFileId || normalizedType !== '3mf') { setPlatesData(null); setPlatesLoading(false); return; } api.getLibraryFilePlates(libraryFileId) .then((data) => setPlatesData(data)) .catch(() => setPlatesData(null)) .finally(() => setPlatesLoading(false)); return; } if (!archiveId) { setPlatesData(null); setPlatesLoading(false); return; } api.getArchivePlates(archiveId) .then((data) => setPlatesData(data)) .catch(() => setPlatesData(null)) .finally(() => setPlatesLoading(false)); }, [archiveId, fileType, isLibrary, libraryFileId]); const plates = useMemo(() => platesData?.plates ?? [], [platesData]); const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1; const splitFullscreen = isFullscreen && hasMultiplePlates; const selectedPlate: PlateMetadata | null = selectedPlateId == null ? null : plates.find((plate) => plate.index === selectedPlateId) ?? null; const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0; const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0); const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount; const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates'); const hasObjectCount = plates.length > 0; const platesGridRef = useRef(null); const platesViewportRef = useRef(null); const [platesPerPage, setPlatesPerPage] = useState(10); const [plateColumns, setPlateColumns] = useState(3); const shouldPaginatePlates = plates.length > platesPerPage; const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage)); const pagedPlates = shouldPaginatePlates ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage) : plates; useEffect(() => { if (!splitFullscreen) { setPlatesPerPage(10); setPlateColumns(3); return; } const grid = platesGridRef.current; const viewport = platesViewportRef.current; if (!grid || !viewport) return; let rafId = 0; const updateLayout = () => { const availableWidth = viewport.clientWidth; const minButtonWidth = 210; const computedCols = Math.floor(availableWidth / minButtonWidth); const nextCols = Math.max(3, Math.min(5, computedCols || 3)); setPlateColumns((prev) => (prev === nextCols ? prev : nextCols)); const computed = window.getComputedStyle(grid); const rowGap = Number.parseFloat(computed.rowGap || '0'); const firstItem = grid.querySelector('button'); const rowHeight = firstItem?.getBoundingClientRect().height ?? 44; const availableHeight = viewport.clientHeight; const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap))); const maxSlots = rows * nextCols; const nextPerPage = Math.max(1, maxSlots - 1); setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage)); }; const scheduleUpdate = () => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(updateLayout); }; scheduleUpdate(); const resizeObserver = new ResizeObserver(scheduleUpdate); resizeObserver.observe(viewport); resizeObserver.observe(grid); return () => { if (rafId) cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; }, [splitFullscreen, plates.length]); useEffect(() => { if (!shouldPaginatePlates) { setPlatePage(0); return; } setPlatePage((prev) => Math.min(prev, totalPlatePages - 1)); }, [plates.length, shouldPaginatePlates, totalPlatePages]); useEffect(() => { if (!shouldPaginatePlates || selectedPlateId == null) return; const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId); if (selectedIndex < 0) return; const nextPage = Math.floor(selectedIndex / platesPerPage); setPlatePage((prev) => (prev === nextPage ? prev : nextPage)); }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]); useEffect(() => { if (!splitFullscreen) { setPlatePanelHeight(null); setHasCustomSplit(false); return; } if (hasCustomSplit) return; const container = splitContainerRef.current; const panel = platesPanelRef.current; if (!container || !panel) return; const containerHeight = container.clientHeight; if (!containerHeight) return; const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio); const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight); const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight); setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight)); }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]); useEffect(() => { if (!isDraggingDivider) return; const handleMouseMove = (event: MouseEvent) => { const container = splitContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const containerHeight = rect.height; if (!containerHeight) return; const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio); const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight); const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top)); setPlatePanelHeight(nextHeight); }; const handleMouseUp = () => { setIsDraggingDivider(false); setHasCustomSplit(true); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]); const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true; const handleOpenInSlicer = async () => { if (!canOpenInSlicer) return; const filename = title || 'model'; try { if (isLibrary) { const { token } = await api.createLibrarySlicerToken(libraryFileId!); const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename); openInSlicer(`${window.location.origin}${path}`, preferredSlicer); } else { const { token } = await api.createArchiveSlicerToken(archiveId!); const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename); openInSlicer(`${window.location.origin}${path}`, preferredSlicer); } } catch { // Fallback to direct URL (works when auth is disabled) if (isLibrary) { const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`; openInSlicer(downloadUrl, preferredSlicer); } else { const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`; openInSlicer(downloadUrl, preferredSlicer); } } }; return (
e.stopPropagation()} > {/* Header */}

{title}

{hasObjectCount && ( {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })} )}
{/* Tabs - only show if we have capabilities */} {capabilities && (
)} {/* Viewer */}
{loading ? (
) : activeTab === '3d' && capabilities ? (
{hasMultiplePlates && (
{t('modelViewer.plates')} {platesLoading && }
{pagedPlates.map((plate) => ( ))}
{(selectedPlate || shouldPaginatePlates) && (
{selectedPlate && (
{t('modelViewer.plateNumber', { number: selectedPlate.index })} {selectedPlate.print_time_seconds != null && ( {t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })} )} {selectedPlate.filament_used_grams != null && ( {selectedPlate.filament_used_grams.toFixed(1)} g )} {selectedPlate.filaments.length > 0 && ( {t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })} )}
)} {shouldPaginatePlates && (
{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}
{(() => { const maxVisible = 5; let start = Math.max(0, platePage - Math.floor(maxVisible / 2)); const end = Math.min(totalPlatePages, start + maxVisible); if (end - start < maxVisible) { start = Math.max(0, end - maxVisible); } const pages = Array.from({ length: end - start }, (_, i) => start + i); return ( <> {start > 0 && ( )} {start > 1 && } {pages.map((pageNumber) => ( ))} {end < totalPlatePages - 1 && } {end < totalPlatePages && ( )} ); })()}
)}
)}
)} {splitFullscreen && (
{ event.preventDefault(); setIsDraggingDivider(true); setHasCustomSplit(true); }} className={`h-2 cursor-row-resize flex items-center justify-center ${ isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary' }`} >
)}
) : activeTab === 'gcode' && capabilities ? ( ) : (
{t('modelViewer.noPreview')}
)}
); }