SkipObjectsModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import { useState } from 'react';
  2. import { useQuery, useMutation } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { useAuth } from '../contexts/AuthContext';
  8. import { ConfirmModal } from './ConfirmModal';
  9. // Custom Skip Objects icon - arrow jumping over boxes
  10. export const SkipObjectsIcon = ({ className }: { className?: string }) => (
  11. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
  12. {/* Three boxes at the bottom */}
  13. <rect x="2" y="15" width="5" height="5" rx="0.5" />
  14. <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
  15. <rect x="17" y="15" width="5" height="5" rx="0.5" />
  16. {/* Curved arrow jumping over first box */}
  17. <path d="M4 12 C4 6, 14 6, 14 12" />
  18. <polyline points="12,10 14,12 12,14" />
  19. </svg>
  20. );
  21. interface SkipObjectsModalProps {
  22. printerId: number;
  23. isOpen: boolean;
  24. onClose: () => void;
  25. }
  26. export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
  27. const { t } = useTranslation();
  28. const { showToast } = useToast();
  29. const { hasPermission } = useAuth();
  30. const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
  31. const [enlarged, setEnlarged] = useState(false);
  32. const { data: status } = useQuery({
  33. queryKey: ['printerStatus', printerId],
  34. queryFn: () => api.getPrinterStatus(printerId),
  35. refetchInterval: 30000,
  36. enabled: isOpen,
  37. });
  38. const { data: objectsData, refetch: refetchObjects } = useQuery({
  39. queryKey: ['printableObjects', printerId],
  40. queryFn: () => api.getPrintableObjects(printerId),
  41. enabled: isOpen,
  42. refetchInterval: isOpen ? 5000 : false,
  43. });
  44. const skipObjectsMutation = useMutation({
  45. mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
  46. onSuccess: (data) => {
  47. showToast(data.message || t('printers.skipObjects.objectsSkipped'));
  48. setPendingSkip(null);
  49. refetchObjects();
  50. },
  51. onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
  52. });
  53. if (!isOpen) return null;
  54. return (
  55. <>
  56. <div
  57. className="fixed inset-0 z-50 flex items-center justify-center"
  58. onClick={onClose}
  59. onKeyDown={(e) => {
  60. if (e.key === 'Escape') {
  61. if (enlarged) setEnlarged(false);
  62. else onClose();
  63. }
  64. }}
  65. tabIndex={-1}
  66. ref={(el) => el?.focus()}
  67. >
  68. {/* Backdrop */}
  69. <div className="absolute inset-0 bg-black/50 z-0" />
  70. {/* Modal */}
  71. <div
  72. className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
  73. onClick={(e) => e.stopPropagation()}
  74. >
  75. {/* Header */}
  76. <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
  77. <div className="flex items-center gap-2">
  78. <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
  79. <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
  80. </div>
  81. <button
  82. onClick={onClose}
  83. className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
  84. >
  85. <X className="w-4 h-4" />
  86. </button>
  87. </div>
  88. {!objectsData ? (
  89. <div className="flex items-center justify-center py-12">
  90. <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
  91. </div>
  92. ) : objectsData.objects.length === 0 ? (
  93. <div className="text-center py-8 px-4 text-bambu-gray">
  94. <p className="text-sm">{t('printers.noObjectsFound')}</p>
  95. <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
  96. </div>
  97. ) : (
  98. <div className="flex flex-col overflow-hidden">
  99. {/* Info Banner */}
  100. <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
  101. <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
  102. <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
  103. </div>
  104. <div className="flex-1 min-w-0">
  105. <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
  106. <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
  107. </div>
  108. <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
  109. {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
  110. </div>
  111. </div>
  112. {/* Layer Warning */}
  113. {(status?.layer_num ?? 0) <= 1 && (
  114. <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
  115. <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
  116. <p className="text-xs text-amber-600 dark:text-amber-400">
  117. {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
  118. </p>
  119. </div>
  120. )}
  121. {/* Content: Image + List side by side */}
  122. <div className="flex flex-1 overflow-hidden">
  123. {/* Left: Preview Image with object markers */}
  124. <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
  125. <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
  126. {status?.cover_url ? (
  127. <img
  128. src={`${status.cover_url}?view=top`}
  129. alt={t('printers.printPreview')}
  130. className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
  131. />
  132. ) : (
  133. <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
  134. <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
  135. </div>
  136. )}
  137. {/* Enlarge hint */}
  138. <div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
  139. <Maximize2 className="w-3.5 h-3.5 text-white" />
  140. </div>
  141. {/* Object ID markers overlay - positioned based on object data */}
  142. {objectsData.objects.length > 0 && (
  143. <div className="absolute inset-0 pointer-events-none">
  144. {objectsData.objects.map((obj, idx) => {
  145. let x: number, y: number;
  146. // Use position data if available, otherwise fall back to grid
  147. if (obj.x != null && obj.y != null && objectsData.bbox_all) {
  148. // bbox_all defines the visible area in the top_N.png image
  149. // Format: [x_min, y_min, x_max, y_max] in mm
  150. const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
  151. const bboxWidth = xMax - xMin;
  152. const bboxHeight = yMax - yMin;
  153. // The image shows bbox_all area with some padding (~5-10%)
  154. const padding = 8;
  155. const contentArea = 100 - (padding * 2);
  156. // Map object position to image percentage
  157. x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
  158. // Y axis: image Y increases downward, but 3D Y increases toward back
  159. y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
  160. // Clamp to valid range
  161. x = Math.max(5, Math.min(95, x));
  162. y = Math.max(5, Math.min(95, y));
  163. } else if (obj.x != null && obj.y != null) {
  164. // Fallback: use full build plate (256mm)
  165. const buildPlate = 256;
  166. x = (obj.x / buildPlate) * 100;
  167. y = 100 - (obj.y / buildPlate) * 100;
  168. x = Math.max(5, Math.min(95, x));
  169. y = Math.max(5, Math.min(95, y));
  170. } else {
  171. // Fallback: arrange in a grid pattern over the build plate area
  172. const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
  173. const row = Math.floor(idx / cols);
  174. const col = idx % cols;
  175. const rows = Math.ceil(objectsData.objects.length / cols);
  176. x = 15 + (col * (70 / cols)) + (35 / cols);
  177. y = 15 + (row * (70 / rows)) + (35 / rows);
  178. }
  179. return (
  180. <div
  181. key={obj.id}
  182. className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
  183. obj.skipped
  184. ? 'bg-red-500 text-white line-through'
  185. : 'bg-bambu-green text-black'
  186. }`}
  187. style={{
  188. left: `${x}%`,
  189. top: `${y}%`,
  190. transform: 'translate(-50%, -50%)'
  191. }}
  192. title={obj.name}
  193. >
  194. {obj.id}
  195. </div>
  196. );
  197. })}
  198. </div>
  199. )}
  200. {/* Object count overlay */}
  201. <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
  202. {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
  203. </div>
  204. </div>
  205. </div>
  206. {/* Right: Object List with prominent IDs */}
  207. <div className="flex-1 min-w-0 overflow-y-auto">
  208. {objectsData.objects.map((obj) => (
  209. <div
  210. key={obj.id}
  211. className={`
  212. flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
  213. ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
  214. `}
  215. >
  216. {/* Large prominent ID badge */}
  217. <div className={`
  218. w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
  219. ${obj.skipped
  220. ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
  221. : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
  222. `}>
  223. <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
  224. {obj.id}
  225. </span>
  226. <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
  227. ID
  228. </span>
  229. </div>
  230. {/* Object name and status */}
  231. <div className="flex-1 min-w-0">
  232. <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
  233. {obj.name}
  234. </span>
  235. {obj.skipped && (
  236. <span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
  237. )}
  238. </div>
  239. {/* Skip button */}
  240. {!obj.skipped ? (
  241. <button
  242. onClick={() => setPendingSkip({ id: obj.id, name: obj.name })}
  243. disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
  244. className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
  245. (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
  246. ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
  247. : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
  248. }`}
  249. title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
  250. >
  251. {t('printers.skipObjects.skip')}
  252. </button>
  253. ) : (
  254. <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
  255. {t('printers.skipObjects.skipped')}
  256. </span>
  257. )}
  258. </div>
  259. ))}
  260. </div>
  261. </div>
  262. </div>
  263. )}
  264. </div>
  265. </div>
  266. {pendingSkip && (
  267. <ConfirmModal
  268. variant="warning"
  269. title={t('printers.skipObjects.confirmTitle')}
  270. message={t('printers.skipObjects.confirmMessage', { name: pendingSkip.name })}
  271. confirmText={t('printers.skipObjects.skip')}
  272. isLoading={skipObjectsMutation.isPending}
  273. onConfirm={() => skipObjectsMutation.mutate([pendingSkip.id])}
  274. onCancel={() => setPendingSkip(null)}
  275. />
  276. )}
  277. {/* Enlarged lightbox overlay */}
  278. {enlarged && objectsData && (
  279. <div
  280. className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
  281. onClick={() => setEnlarged(false)}
  282. >
  283. <button
  284. onClick={() => setEnlarged(false)}
  285. className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
  286. >
  287. <X className="w-6 h-6" />
  288. </button>
  289. <div
  290. className="relative max-w-[600px] max-h-[80vh] aspect-square"
  291. onClick={(e) => e.stopPropagation()}
  292. >
  293. {status?.cover_url ? (
  294. <img
  295. src={`${status.cover_url}?view=top`}
  296. alt={t('printers.printPreview')}
  297. className="w-full h-full object-contain rounded-lg bg-gray-900"
  298. />
  299. ) : (
  300. <div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
  301. <Box className="w-16 h-16 text-gray-500" />
  302. </div>
  303. )}
  304. {/* Object ID markers overlay */}
  305. {objectsData.objects.length > 0 && (
  306. <div className="absolute inset-0 pointer-events-none">
  307. {objectsData.objects.map((obj, idx) => {
  308. let x: number, y: number;
  309. if (obj.x != null && obj.y != null && objectsData.bbox_all) {
  310. const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
  311. const bboxWidth = xMax - xMin;
  312. const bboxHeight = yMax - yMin;
  313. const padding = 8;
  314. const contentArea = 100 - (padding * 2);
  315. x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
  316. y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
  317. x = Math.max(5, Math.min(95, x));
  318. y = Math.max(5, Math.min(95, y));
  319. } else if (obj.x != null && obj.y != null) {
  320. const buildPlate = 256;
  321. x = (obj.x / buildPlate) * 100;
  322. y = 100 - (obj.y / buildPlate) * 100;
  323. x = Math.max(5, Math.min(95, x));
  324. y = Math.max(5, Math.min(95, y));
  325. } else {
  326. const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
  327. const row = Math.floor(idx / cols);
  328. const col = idx % cols;
  329. const rows = Math.ceil(objectsData.objects.length / cols);
  330. x = 15 + (col * (70 / cols)) + (35 / cols);
  331. y = 15 + (row * (70 / rows)) + (35 / rows);
  332. }
  333. return (
  334. <div
  335. key={obj.id}
  336. className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
  337. obj.skipped
  338. ? 'bg-red-500 text-white line-through'
  339. : 'bg-bambu-green text-black'
  340. }`}
  341. style={{
  342. left: `${x}%`,
  343. top: `${y}%`,
  344. transform: 'translate(-50%, -50%)'
  345. }}
  346. title={obj.name}
  347. >
  348. {obj.id}
  349. </div>
  350. );
  351. })}
  352. </div>
  353. )}
  354. {/* Active count badge */}
  355. <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
  356. {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
  357. </div>
  358. </div>
  359. </div>
  360. )}
  361. </>
  362. );
  363. }