|
|
@@ -1,5 +1,5 @@
|
|
|
-import { useState, useEffect } from 'react';
|
|
|
-import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
|
|
|
+import { useState, useEffect, useRef, useMemo } from 'react';
|
|
|
+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';
|
|
|
@@ -33,6 +33,17 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|
|
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(() => {
|
|
|
@@ -91,6 +102,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|
|
useEffect(() => {
|
|
|
setPlatesLoading(true);
|
|
|
setSelectedPlateId(null);
|
|
|
+ setPlatePage(0);
|
|
|
|
|
|
if (isLibrary) {
|
|
|
const normalizedType = (fileType || '').toLowerCase();
|
|
|
@@ -118,11 +130,131 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|
|
.finally(() => setPlatesLoading(false));
|
|
|
}, [archiveId, fileType, isLibrary, libraryFileId]);
|
|
|
|
|
|
- const plates = platesData?.plates ?? [];
|
|
|
+ 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 ? `Plate ${selectedPlate.index}` : 'All plates';
|
|
|
+ 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;
|
|
|
|
|
|
@@ -141,21 +273,38 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
- className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
|
|
|
+ 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 rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
|
|
|
+ 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">
|
|
|
- <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
|
|
|
+ <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}: {selectedObjectCount} object{selectedObjectCount !== 1 ? 's' : ''}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
|
|
|
<ExternalLink className="w-4 h-4" />
|
|
|
Open in Slicer
|
|
|
</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>
|
|
|
@@ -205,92 +354,226 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
|
|
</div>
|
|
|
) : activeTab === '3d' && capabilities ? (
|
|
|
- <div className="w-full h-full flex flex-col gap-3">
|
|
|
+ <div
|
|
|
+ ref={splitContainerRef}
|
|
|
+ className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
|
|
|
+ >
|
|
|
{hasMultiplePlates && (
|
|
|
- <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
|
|
|
+ <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" />
|
|
|
Plates
|
|
|
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
|
</div>
|
|
|
- <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedPlateId(null)}
|
|
|
- className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
|
|
|
- selectedPlateId == null
|
|
|
- ? 'border-bambu-green bg-bambu-green/10'
|
|
|
- : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
|
- }`}
|
|
|
- >
|
|
|
- <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
|
|
|
- <Layers className="w-5 h-5 text-bambu-gray" />
|
|
|
- </div>
|
|
|
- <div className="min-w-0 flex-1">
|
|
|
- <p className="text-sm text-white font-medium truncate">All Plates</p>
|
|
|
- <p className="text-xs text-bambu-gray truncate">
|
|
|
- {plates.length} plate{plates.length !== 1 ? 's' : ''}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- {selectedPlateId == null && (
|
|
|
- <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
|
|
- )}
|
|
|
- </button>
|
|
|
- {plates.map((plate) => (
|
|
|
- <button
|
|
|
- key={plate.index}
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedPlateId(plate.index)}
|
|
|
- className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
|
|
|
- selectedPlateId === plate.index
|
|
|
- ? 'border-bambu-green bg-bambu-green/10'
|
|
|
- : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
|
- }`}
|
|
|
+ <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}
|
|
|
>
|
|
|
- {plate.has_thumbnail && plate.thumbnail_url ? (
|
|
|
- <img
|
|
|
- src={plate.thumbnail_url}
|
|
|
- alt={`Plate ${plate.index}`}
|
|
|
- className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
|
|
|
- />
|
|
|
- ) : (
|
|
|
- <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
|
|
|
- <Layers className="w-5 h-5 text-bambu-gray" />
|
|
|
+ <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`}>All Plates</p>
|
|
|
+ <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
|
+ {plates.length} plate{plates.length !== 1 ? 's' : ''}
|
|
|
+ </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={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 || `Plate ${plate.index}`}
|
|
|
+ </p>
|
|
|
+ <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
|
+ {(() => {
|
|
|
+ const objectCount = plate.object_count ?? plate.objects?.length ?? 0;
|
|
|
+ return `${objectCount} object${objectCount !== 1 ? 's' : ''}`;
|
|
|
+ })()}
|
|
|
+ </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>Plate {selectedPlate.index}</span>
|
|
|
+ {selectedPlate.print_time_seconds != null && (
|
|
|
+ <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
|
|
|
+ )}
|
|
|
+ {selectedPlate.filament_used_grams != null && (
|
|
|
+ <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
|
|
+ )}
|
|
|
+ {selectedPlate.filaments.length > 0 && (
|
|
|
+ <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
- <div className="min-w-0 flex-1">
|
|
|
- <p className="text-sm text-white font-medium truncate">
|
|
|
- {plate.name || `Plate ${plate.index}`}
|
|
|
- </p>
|
|
|
- <p className="text-xs text-bambu-gray truncate">
|
|
|
- {plate.objects.length > 0
|
|
|
- ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
|
|
|
- : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- {selectedPlateId === plate.index && (
|
|
|
- <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
|
|
+ {shouldPaginatePlates && (
|
|
|
+ <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
|
|
|
+ <span>Page {platePage + 1} of {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'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ 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'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ Next
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)}
|
|
|
- </button>
|
|
|
- ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- {selectedPlate && (
|
|
|
- <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
|
|
|
- <span>Plate {selectedPlate.index}</span>
|
|
|
- {selectedPlate.print_time_seconds != null && (
|
|
|
- <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
|
|
|
- )}
|
|
|
- {selectedPlate.filament_used_grams != null && (
|
|
|
- <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
|
|
- )}
|
|
|
- {selectedPlate.filaments.length > 0 && (
|
|
|
- <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
</div>
|
|
|
)}
|
|
|
- <div className="flex-1">
|
|
|
+ {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!)
|