ModelViewerModal.tsx 29 KB

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