ModelViewerModal.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import { useState, useEffect, useRef, useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
  4. import { ModelViewer } from './ModelViewer';
  5. import { GcodeViewer } from './GcodeViewer';
  6. import { Button } from './Button';
  7. import { api } from '../api/client';
  8. import { openInSlicer } from '../utils/slicer';
  9. import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
  10. type ViewTab = '3d' | 'gcode';
  11. interface ModelViewerModalProps {
  12. archiveId?: number;
  13. libraryFileId?: number;
  14. title: string;
  15. fileType?: string;
  16. onClose: () => void;
  17. }
  18. interface Capabilities {
  19. has_model: boolean;
  20. has_gcode: boolean;
  21. has_source: boolean;
  22. build_volume: { x: number; y: number; z: number };
  23. filament_colors: string[];
  24. }
  25. export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
  26. const { t } = useTranslation();
  27. const isLibrary = libraryFileId != null;
  28. const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
  29. const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
  30. const [loading, setLoading] = useState(true);
  31. const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
  32. const [platesLoading, setPlatesLoading] = useState(false);
  33. const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
  34. const [platePage, setPlatePage] = useState(0);
  35. const [isFullscreen, setIsFullscreen] = useState(false);
  36. const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
  37. const [isDraggingDivider, setIsDraggingDivider] = useState(false);
  38. const [hasCustomSplit, setHasCustomSplit] = useState(false);
  39. const splitContainerRef = useRef<HTMLDivElement>(null);
  40. const platesPanelRef = useRef<HTMLDivElement>(null);
  41. const dividerHeight = 10;
  42. const minPlateHeight = 160;
  43. const minViewerPx = 240;
  44. const minViewerRatio = 0.35;
  45. // Close on Escape key
  46. useEffect(() => {
  47. const handleKeyDown = (e: KeyboardEvent) => {
  48. if (e.key === 'Escape') onClose();
  49. };
  50. window.addEventListener('keydown', handleKeyDown);
  51. return () => window.removeEventListener('keydown', handleKeyDown);
  52. }, [onClose]);
  53. useEffect(() => {
  54. setLoading(true);
  55. if (isLibrary) {
  56. const normalizedType = (fileType || '').toLowerCase();
  57. const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
  58. const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
  59. setCapabilities({
  60. has_model: hasModel,
  61. has_gcode: hasGcode,
  62. has_source: false,
  63. build_volume: { x: 256, y: 256, z: 256 },
  64. filament_colors: [],
  65. });
  66. setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
  67. setLoading(false);
  68. return;
  69. }
  70. if (!archiveId) {
  71. setCapabilities(null);
  72. setActiveTab(null);
  73. setLoading(false);
  74. return;
  75. }
  76. api.getArchiveCapabilities(archiveId)
  77. .then(caps => {
  78. setCapabilities(caps);
  79. // Auto-select the first available tab
  80. if (caps.has_model) {
  81. setActiveTab('3d');
  82. } else if (caps.has_gcode) {
  83. setActiveTab('gcode');
  84. }
  85. setLoading(false);
  86. })
  87. .catch(() => {
  88. // Fallback to 3D model tab if capabilities check fails
  89. setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
  90. setActiveTab('3d');
  91. setLoading(false);
  92. });
  93. }, [archiveId, fileType, isLibrary]);
  94. useEffect(() => {
  95. setPlatesLoading(true);
  96. setSelectedPlateId(null);
  97. setPlatePage(0);
  98. if (isLibrary) {
  99. const normalizedType = (fileType || '').toLowerCase();
  100. if (!libraryFileId || normalizedType !== '3mf') {
  101. setPlatesData(null);
  102. setPlatesLoading(false);
  103. return;
  104. }
  105. api.getLibraryFilePlates(libraryFileId)
  106. .then((data) => setPlatesData(data))
  107. .catch(() => setPlatesData(null))
  108. .finally(() => setPlatesLoading(false));
  109. return;
  110. }
  111. if (!archiveId) {
  112. setPlatesData(null);
  113. setPlatesLoading(false);
  114. return;
  115. }
  116. api.getArchivePlates(archiveId)
  117. .then((data) => setPlatesData(data))
  118. .catch(() => setPlatesData(null))
  119. .finally(() => setPlatesLoading(false));
  120. }, [archiveId, fileType, isLibrary, libraryFileId]);
  121. const plates = useMemo(() => platesData?.plates ?? [], [platesData]);
  122. const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
  123. const splitFullscreen = isFullscreen && hasMultiplePlates;
  124. const selectedPlate: PlateMetadata | null = selectedPlateId == null
  125. ? null
  126. : plates.find((plate) => plate.index === selectedPlateId) ?? null;
  127. const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0;
  128. const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0);
  129. const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount;
  130. const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates');
  131. const hasObjectCount = plates.length > 0;
  132. const platesGridRef = useRef<HTMLDivElement>(null);
  133. const platesViewportRef = useRef<HTMLDivElement>(null);
  134. const [platesPerPage, setPlatesPerPage] = useState(10);
  135. const [plateColumns, setPlateColumns] = useState(3);
  136. const shouldPaginatePlates = plates.length > platesPerPage;
  137. const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage));
  138. const pagedPlates = shouldPaginatePlates
  139. ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage)
  140. : plates;
  141. useEffect(() => {
  142. if (!splitFullscreen) {
  143. setPlatesPerPage(10);
  144. setPlateColumns(3);
  145. return;
  146. }
  147. const grid = platesGridRef.current;
  148. const viewport = platesViewportRef.current;
  149. if (!grid || !viewport) return;
  150. let rafId = 0;
  151. const updateLayout = () => {
  152. const availableWidth = viewport.clientWidth;
  153. const minButtonWidth = 210;
  154. const computedCols = Math.floor(availableWidth / minButtonWidth);
  155. const nextCols = Math.max(3, Math.min(5, computedCols || 3));
  156. setPlateColumns((prev) => (prev === nextCols ? prev : nextCols));
  157. const computed = window.getComputedStyle(grid);
  158. const rowGap = Number.parseFloat(computed.rowGap || '0');
  159. const firstItem = grid.querySelector<HTMLElement>('button');
  160. const rowHeight = firstItem?.getBoundingClientRect().height ?? 44;
  161. const availableHeight = viewport.clientHeight;
  162. const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)));
  163. const maxSlots = rows * nextCols;
  164. const nextPerPage = Math.max(1, maxSlots - 1);
  165. setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage));
  166. };
  167. const scheduleUpdate = () => {
  168. if (rafId) cancelAnimationFrame(rafId);
  169. rafId = requestAnimationFrame(updateLayout);
  170. };
  171. scheduleUpdate();
  172. const resizeObserver = new ResizeObserver(scheduleUpdate);
  173. resizeObserver.observe(viewport);
  174. resizeObserver.observe(grid);
  175. return () => {
  176. if (rafId) cancelAnimationFrame(rafId);
  177. resizeObserver.disconnect();
  178. };
  179. }, [splitFullscreen, plates.length]);
  180. useEffect(() => {
  181. if (!shouldPaginatePlates) {
  182. setPlatePage(0);
  183. return;
  184. }
  185. setPlatePage((prev) => Math.min(prev, totalPlatePages - 1));
  186. }, [plates.length, shouldPaginatePlates, totalPlatePages]);
  187. useEffect(() => {
  188. if (!shouldPaginatePlates || selectedPlateId == null) return;
  189. const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId);
  190. if (selectedIndex < 0) return;
  191. const nextPage = Math.floor(selectedIndex / platesPerPage);
  192. setPlatePage((prev) => (prev === nextPage ? prev : nextPage));
  193. }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]);
  194. useEffect(() => {
  195. if (!splitFullscreen) {
  196. setPlatePanelHeight(null);
  197. setHasCustomSplit(false);
  198. return;
  199. }
  200. if (hasCustomSplit) return;
  201. const container = splitContainerRef.current;
  202. const panel = platesPanelRef.current;
  203. if (!container || !panel) return;
  204. const containerHeight = container.clientHeight;
  205. if (!containerHeight) return;
  206. const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
  207. const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
  208. const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight);
  209. setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight));
  210. }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
  211. useEffect(() => {
  212. if (!isDraggingDivider) return;
  213. const handleMouseMove = (event: MouseEvent) => {
  214. const container = splitContainerRef.current;
  215. if (!container) return;
  216. const rect = container.getBoundingClientRect();
  217. const containerHeight = rect.height;
  218. if (!containerHeight) return;
  219. const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
  220. const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
  221. const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top));
  222. setPlatePanelHeight(nextHeight);
  223. };
  224. const handleMouseUp = () => {
  225. setIsDraggingDivider(false);
  226. setHasCustomSplit(true);
  227. };
  228. document.addEventListener('mousemove', handleMouseMove);
  229. document.addEventListener('mouseup', handleMouseUp);
  230. document.body.style.cursor = 'row-resize';
  231. document.body.style.userSelect = 'none';
  232. return () => {
  233. document.removeEventListener('mousemove', handleMouseMove);
  234. document.removeEventListener('mouseup', handleMouseUp);
  235. document.body.style.cursor = '';
  236. document.body.style.userSelect = '';
  237. };
  238. }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
  239. const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
  240. const handleOpenInSlicer = () => {
  241. if (!canOpenInSlicer) return;
  242. // URL must include .3mf filename for Bambu Studio to recognize the format
  243. const filename = title || 'model';
  244. if (isLibrary) {
  245. const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
  246. openInSlicer(downloadUrl);
  247. return;
  248. }
  249. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
  250. openInSlicer(downloadUrl);
  251. };
  252. return (
  253. <div
  254. className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
  255. onClick={onClose}
  256. >
  257. <div
  258. className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
  259. isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
  260. }`}
  261. onClick={(e) => e.stopPropagation()}
  262. >
  263. {/* Header */}
  264. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  265. <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
  266. <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
  267. {hasObjectCount && (
  268. <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
  269. {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
  270. </span>
  271. )}
  272. </div>
  273. <div className="flex items-center gap-2">
  274. <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
  275. <ExternalLink className="w-4 h-4" />
  276. {t('modelViewer.openInSlicer')}
  277. </Button>
  278. <Button
  279. variant="secondary"
  280. size="sm"
  281. onClick={() => setIsFullscreen((prev) => !prev)}
  282. title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
  283. >
  284. {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
  285. </Button>
  286. <Button variant="ghost" size="sm" onClick={onClose}>
  287. <X className="w-5 h-5" />
  288. </Button>
  289. </div>
  290. </div>
  291. {/* Tabs - only show if we have capabilities */}
  292. {capabilities && (
  293. <div className="flex border-b border-bambu-dark-tertiary">
  294. <button
  295. onClick={() => capabilities.has_model && setActiveTab('3d')}
  296. disabled={!capabilities.has_model}
  297. className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
  298. activeTab === '3d'
  299. ? 'text-bambu-green border-b-2 border-bambu-green'
  300. : capabilities.has_model
  301. ? 'text-bambu-gray hover:text-white'
  302. : 'text-bambu-gray/30 cursor-not-allowed'
  303. }`}
  304. >
  305. <Box className="w-4 h-4" />
  306. {t('modelViewer.tabs.model')}
  307. {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
  308. </button>
  309. <button
  310. onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
  311. disabled={!capabilities.has_gcode}
  312. className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
  313. activeTab === 'gcode'
  314. ? 'text-bambu-green border-b-2 border-bambu-green'
  315. : capabilities.has_gcode
  316. ? 'text-bambu-gray hover:text-white'
  317. : 'text-bambu-gray/30 cursor-not-allowed'
  318. }`}
  319. >
  320. <Code2 className="w-4 h-4" />
  321. {t('modelViewer.tabs.gcode')}
  322. {!capabilities.has_gcode && <span className="text-xs">({t('modelViewer.notSliced')})</span>}
  323. </button>
  324. </div>
  325. )}
  326. {/* Viewer */}
  327. <div className="flex-1 overflow-hidden p-4">
  328. {loading ? (
  329. <div className="w-full h-full flex items-center justify-center">
  330. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  331. </div>
  332. ) : activeTab === '3d' && capabilities ? (
  333. <div
  334. ref={splitContainerRef}
  335. className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
  336. >
  337. {hasMultiplePlates && (
  338. <div
  339. ref={platesPanelRef}
  340. style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
  341. className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
  342. >
  343. <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
  344. <Layers className="w-4 h-4" />
  345. {t('modelViewer.plates')}
  346. {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
  347. </div>
  348. <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
  349. <div
  350. ref={platesViewportRef}
  351. className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
  352. >
  353. <div
  354. ref={platesGridRef}
  355. className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
  356. style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
  357. >
  358. <button
  359. type="button"
  360. onClick={() => setSelectedPlateId(null)}
  361. className={`flex items-center rounded-lg border text-left transition-colors ${
  362. splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
  363. } ${
  364. selectedPlateId == null
  365. ? 'border-bambu-green bg-bambu-green/10'
  366. : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
  367. }`}
  368. >
  369. <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
  370. splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
  371. }`}>
  372. <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
  373. </div>
  374. <div className="min-w-0 flex-1">
  375. <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
  376. <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
  377. {t('modelViewer.plateCount', { count: plates.length })}
  378. </p>
  379. </div>
  380. {selectedPlateId == null && (
  381. <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
  382. )}
  383. </button>
  384. {pagedPlates.map((plate) => (
  385. <button
  386. key={plate.index}
  387. type="button"
  388. onClick={() => setSelectedPlateId(plate.index)}
  389. className={`flex items-center rounded-lg border text-left transition-colors ${
  390. splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
  391. } ${
  392. selectedPlateId === plate.index
  393. ? 'border-bambu-green bg-bambu-green/10'
  394. : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
  395. }`}
  396. >
  397. {plate.has_thumbnail && plate.thumbnail_url ? (
  398. <img
  399. src={plate.thumbnail_url}
  400. alt={`Plate ${plate.index}`}
  401. className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
  402. />
  403. ) : (
  404. <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
  405. splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
  406. }`}>
  407. <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
  408. </div>
  409. )}
  410. <div className="min-w-0 flex-1">
  411. <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
  412. {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
  413. </p>
  414. <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
  415. {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
  416. </p>
  417. </div>
  418. {selectedPlateId === plate.index && (
  419. <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
  420. )}
  421. </button>
  422. ))}
  423. </div>
  424. </div>
  425. {(selectedPlate || shouldPaginatePlates) && (
  426. <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
  427. {selectedPlate && (
  428. <div className="flex items-center gap-3 whitespace-nowrap">
  429. <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
  430. {selectedPlate.print_time_seconds != null && (
  431. <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
  432. )}
  433. {selectedPlate.filament_used_grams != null && (
  434. <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
  435. )}
  436. {selectedPlate.filaments.length > 0 && (
  437. <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
  438. )}
  439. </div>
  440. )}
  441. {shouldPaginatePlates && (
  442. <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
  443. <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
  444. <div className="flex items-center gap-1">
  445. <button
  446. type="button"
  447. onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
  448. disabled={platePage === 0}
  449. className={`px-2 py-1 rounded border text-xs ${
  450. platePage === 0
  451. ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
  452. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  453. }`}
  454. >
  455. {t('modelViewer.pagination.prev')}
  456. </button>
  457. {(() => {
  458. const maxVisible = 5;
  459. let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
  460. const end = Math.min(totalPlatePages, start + maxVisible);
  461. if (end - start < maxVisible) {
  462. start = Math.max(0, end - maxVisible);
  463. }
  464. const pages = Array.from({ length: end - start }, (_, i) => start + i);
  465. return (
  466. <>
  467. {start > 0 && (
  468. <button
  469. type="button"
  470. onClick={() => setPlatePage(0)}
  471. className={`px-2 py-1 rounded border text-xs ${
  472. platePage === 0
  473. ? 'border-bambu-green text-bambu-green'
  474. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  475. }`}
  476. >
  477. 1
  478. </button>
  479. )}
  480. {start > 1 && <span className="px-1">…</span>}
  481. {pages.map((pageNumber) => (
  482. <button
  483. key={pageNumber}
  484. type="button"
  485. onClick={() => setPlatePage(pageNumber)}
  486. className={`px-2 py-1 rounded border text-xs ${
  487. platePage === pageNumber
  488. ? 'border-bambu-green text-bambu-green'
  489. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  490. }`}
  491. >
  492. {pageNumber + 1}
  493. </button>
  494. ))}
  495. {end < totalPlatePages - 1 && <span className="px-1">…</span>}
  496. {end < totalPlatePages && (
  497. <button
  498. type="button"
  499. onClick={() => setPlatePage(totalPlatePages - 1)}
  500. className={`px-2 py-1 rounded border text-xs ${
  501. platePage === totalPlatePages - 1
  502. ? 'border-bambu-green text-bambu-green'
  503. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  504. }`}
  505. >
  506. {totalPlatePages}
  507. </button>
  508. )}
  509. </>
  510. );
  511. })()}
  512. <button
  513. type="button"
  514. onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
  515. disabled={platePage >= totalPlatePages - 1}
  516. className={`px-2 py-1 rounded border text-xs ${
  517. platePage >= totalPlatePages - 1
  518. ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
  519. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  520. }`}
  521. >
  522. {t('modelViewer.pagination.next')}
  523. </button>
  524. </div>
  525. </div>
  526. )}
  527. </div>
  528. )}
  529. </div>
  530. </div>
  531. )}
  532. {splitFullscreen && (
  533. <div
  534. role="separator"
  535. aria-orientation="horizontal"
  536. onMouseDown={(event) => {
  537. event.preventDefault();
  538. setIsDraggingDivider(true);
  539. setHasCustomSplit(true);
  540. }}
  541. className={`h-2 cursor-row-resize flex items-center justify-center ${
  542. isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
  543. }`}
  544. >
  545. <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
  546. </div>
  547. )}
  548. <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
  549. <ModelViewer
  550. url={isLibrary
  551. ? api.getLibraryFileDownloadUrl(libraryFileId!)
  552. : (capabilities.has_source
  553. ? api.getSource3mfDownloadUrl(archiveId!)
  554. : api.getArchiveDownload(archiveId!))}
  555. fileType={fileType}
  556. buildVolume={capabilities.build_volume}
  557. filamentColors={capabilities.filament_colors}
  558. selectedPlateId={selectedPlateId}
  559. className="w-full h-full"
  560. />
  561. </div>
  562. </div>
  563. ) : activeTab === 'gcode' && capabilities ? (
  564. <GcodeViewer
  565. gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
  566. filamentColors={capabilities.filament_colors}
  567. className="w-full h-full"
  568. />
  569. ) : (
  570. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  571. {t('modelViewer.noPreview')}
  572. </div>
  573. )}
  574. </div>
  575. </div>
  576. </div>
  577. );
  578. }