mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
The File Manager 3D Preview dialog (ModelViewerModal) rendered plate thumbnails with the raw thumbnail_url. The plate-thumbnail endpoints are gated behind a camera stream token passed as ?token= (an <img> can't send an Authorization header), so with auth enabled the browser fetched without a token and got 401 "Valid camera stream token required" — broken image icons for every plate. The Slice dialog's picker (PlatePickerModal) and the Print modal's PlateSelector already append the token via withStreamToken(), which is why the same file's thumbnails showed there. Wrap the thumbnail src in withStreamToken(), matching the other two call sites. The token is synced app-wide and withStreamToken() is a no-op when auth is off, so non-auth setups are unchanged.
661 lines
32 KiB
TypeScript
661 lines
32 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
|
|
import { ModelViewer } from './ModelViewer';
|
|
import { GcodeViewer } from './GcodeViewer';
|
|
import { Button } from './Button';
|
|
import { api, withStreamToken } 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;
|
|
// When set and `settings.use_slicer_api` is on, the header's slicer button
|
|
// becomes "Slice" and calls this instead of opening BambuStudio / Orca
|
|
// externally — so the preview modal's slice action matches the file row's
|
|
// Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
|
|
onSliceWithBambuddy?: () => 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, onSliceWithBambuddy }: ModelViewerModalProps) {
|
|
const { t } = useTranslation();
|
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
|
|
// Desktop "Open in Slicer" target — falls back to preferred_slicer when the
|
|
// user hasn't explicitly chosen a different desktop slicer (#1329). This
|
|
// variable is only used for URI-handoff; sidecar slicing keeps using
|
|
// preferred_slicer directly.
|
|
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
|
|
const isLibrary = libraryFileId != null;
|
|
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
|
|
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
|
|
const [platesLoading, setPlatesLoading] = useState(false);
|
|
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
|
|
const [platePage, setPlatePage] = useState(0);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
|
|
const [isDraggingDivider, setIsDraggingDivider] = useState(false);
|
|
const [hasCustomSplit, setHasCustomSplit] = useState(false);
|
|
const splitContainerRef = useRef<HTMLDivElement>(null);
|
|
const platesPanelRef = useRef<HTMLDivElement>(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();
|
|
// A `.gcode.3mf` file is the slicer's sliced output — it carries
|
|
// both the per-plate model (in `3D/3dmodel.model`) and the g-code
|
|
// for the active plate (in `Metadata/plate_*.gcode`). The backend
|
|
// library scan path (library.py) tags it `gcode.3mf` while the
|
|
// upload path tags it `3mf`, so we accept both shapes here for
|
|
// the 3D-tab + g-code-tab gating (#1543).
|
|
const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
|
|
const hasModel = isThreeMfFamily || normalizedType === 'stl';
|
|
const hasGcode = isThreeMfFamily || normalizedType === 'gcode';
|
|
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();
|
|
// Same 3mf-family gate as the capabilities branch above — sliced
|
|
// `.gcode.3mf` files have plate metadata too (#1543).
|
|
const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
|
|
if (!libraryFileId || !isThreeMfFamily) {
|
|
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<HTMLDivElement>(null);
|
|
const platesViewportRef = useRef<HTMLDivElement>(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<HTMLElement>('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;
|
|
|
|
// When the user has the in-app Slicer API enabled (Settings → Workflow →
|
|
// Slicer → Use Slicer API), library-mode previews route the header's slicer
|
|
// button into Bambuddy's own SliceModal — same behaviour as the Cog button
|
|
// in the file-row actions. Falls back to the external-slicer launcher when
|
|
// the API is off, when no in-app handler is wired (e.g. archive preview),
|
|
// or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
|
|
const sliceableType = (() => {
|
|
const t = (fileType || '').toLowerCase();
|
|
return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
|
|
})();
|
|
const useBambuddySlicer = Boolean(
|
|
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
|
|
);
|
|
|
|
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 (
|
|
<div
|
|
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
|
|
isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
|
|
}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
|
|
<h2 className="text-lg font-semibold text-white truncate">{title}</h2>
|
|
{hasObjectCount && (
|
|
<span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
|
|
{objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{useBambuddySlicer ? (
|
|
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
|
|
<Cog className="w-4 h-4" />
|
|
{t('slice.action')}
|
|
</Button>
|
|
) : (
|
|
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
|
|
<ExternalLink className="w-4 h-4" />
|
|
{t('modelViewer.openInSlicer')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setIsFullscreen((prev) => !prev)}
|
|
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
>
|
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
<X className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs - only show if we have capabilities */}
|
|
{capabilities && (
|
|
<div className="flex border-b border-bambu-dark-tertiary">
|
|
<button
|
|
onClick={() => capabilities.has_model && setActiveTab('3d')}
|
|
disabled={!capabilities.has_model}
|
|
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === '3d'
|
|
? 'text-bambu-green border-b-2 border-bambu-green'
|
|
: capabilities.has_model
|
|
? 'text-bambu-gray hover:text-white'
|
|
: 'text-bambu-gray/30 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<Box className="w-4 h-4" />
|
|
{t('modelViewer.tabs.model')}
|
|
{!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
|
|
</button>
|
|
<button
|
|
onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
|
|
disabled={!capabilities.has_gcode}
|
|
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === 'gcode'
|
|
? 'text-bambu-green border-b-2 border-bambu-green'
|
|
: capabilities.has_gcode
|
|
? 'text-bambu-gray hover:text-white'
|
|
: 'text-bambu-gray/30 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<Code2 className="w-4 h-4" />
|
|
{t('modelViewer.tabs.gcode')}
|
|
{!capabilities.has_gcode && <span className="text-xs">({t('modelViewer.notSliced')})</span>}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Viewer */}
|
|
<div className="flex-1 overflow-hidden p-4">
|
|
{loading ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
|
</div>
|
|
) : activeTab === '3d' && capabilities ? (
|
|
<div
|
|
ref={splitContainerRef}
|
|
className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
|
|
>
|
|
{hasMultiplePlates && (
|
|
<div
|
|
ref={platesPanelRef}
|
|
style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
|
|
className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
|
|
<Layers className="w-4 h-4" />
|
|
{t('modelViewer.plates')}
|
|
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
</div>
|
|
<div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
|
|
<div
|
|
ref={platesViewportRef}
|
|
className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
|
|
>
|
|
<div
|
|
ref={platesGridRef}
|
|
className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
|
|
style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPlateId(null)}
|
|
className={`flex items-center rounded-lg border text-left transition-colors ${
|
|
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
|
} ${
|
|
selectedPlateId == null
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
|
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
|
}`}>
|
|
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
|
|
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
{t('modelViewer.plateCount', { count: plates.length })}
|
|
</p>
|
|
</div>
|
|
{selectedPlateId == null && (
|
|
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
|
)}
|
|
</button>
|
|
{pagedPlates.map((plate) => (
|
|
<button
|
|
key={plate.index}
|
|
type="button"
|
|
onClick={() => setSelectedPlateId(plate.index)}
|
|
className={`flex items-center rounded-lg border text-left transition-colors ${
|
|
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
|
} ${
|
|
selectedPlateId === plate.index
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{plate.has_thumbnail && plate.thumbnail_url ? (
|
|
<img
|
|
src={withStreamToken(plate.thumbnail_url)}
|
|
alt={`Plate ${plate.index}`}
|
|
className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
|
|
/>
|
|
) : (
|
|
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
|
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
|
}`}>
|
|
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
|
|
{plate.name || t('modelViewer.plateNumber', { number: plate.index })}
|
|
</p>
|
|
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
{t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
|
|
</p>
|
|
</div>
|
|
{selectedPlateId === plate.index && (
|
|
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{(selectedPlate || shouldPaginatePlates) && (
|
|
<div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
|
|
{selectedPlate && (
|
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
|
<span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
|
|
{selectedPlate.print_time_seconds != null && (
|
|
<span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
|
|
)}
|
|
{selectedPlate.filament_used_grams != null && (
|
|
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
|
)}
|
|
{selectedPlate.filaments.length > 0 && (
|
|
<span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{shouldPaginatePlates && (
|
|
<div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
|
|
<span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
|
|
disabled={platePage === 0}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === 0
|
|
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{t('modelViewer.pagination.prev')}
|
|
</button>
|
|
{(() => {
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage(0)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === 0
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
1
|
|
</button>
|
|
)}
|
|
{start > 1 && <span className="px-1">…</span>}
|
|
{pages.map((pageNumber) => (
|
|
<button
|
|
key={pageNumber}
|
|
type="button"
|
|
onClick={() => setPlatePage(pageNumber)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === pageNumber
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{pageNumber + 1}
|
|
</button>
|
|
))}
|
|
{end < totalPlatePages - 1 && <span className="px-1">…</span>}
|
|
{end < totalPlatePages && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage(totalPlatePages - 1)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === totalPlatePages - 1
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{totalPlatePages}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
|
|
disabled={platePage >= totalPlatePages - 1}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage >= totalPlatePages - 1
|
|
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{t('modelViewer.pagination.next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{splitFullscreen && (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
onMouseDown={(event) => {
|
|
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'
|
|
}`}
|
|
>
|
|
<div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
|
|
</div>
|
|
)}
|
|
<div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
|
|
<ModelViewer
|
|
url={isLibrary
|
|
? api.getLibraryFileDownloadUrl(libraryFileId!)
|
|
: (capabilities.has_source
|
|
? api.getSource3mfDownloadUrl(archiveId!)
|
|
: api.getArchiveDownload(archiveId!))}
|
|
fileType={fileType}
|
|
buildVolume={capabilities.build_volume}
|
|
filamentColors={capabilities.filament_colors}
|
|
selectedPlateId={selectedPlateId}
|
|
className="w-full h-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : activeTab === 'gcode' && capabilities ? (
|
|
<GcodeViewer
|
|
gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
|
|
filamentColors={capabilities.filament_colors}
|
|
className="w-full h-full"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
|
{t('modelViewer.noPreview')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|