ModelViewerModal.tsx 32 KB

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