mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
The mini thumbnail wrapped its src with withStreamToken() (appends the
short-lived camera-stream token, needed because <img> can't send an
Authorization header), but the enlarged lightbox <img> used a bare
${status.cover_url}?view=top. On auth-enabled instances the backend
rejected the unauthenticated request and the browser showed the
broken-image icon. Wrap the enlarged src with withStreamToken() too.
385 lines
18 KiB
TypeScript
385 lines
18 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
|
|
import { api, withStreamToken } from '../api/client';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
|
|
// Custom Skip Objects icon - arrow jumping over boxes
|
|
export const SkipObjectsIcon = ({ className }: { className?: string }) => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
{/* Three boxes at the bottom */}
|
|
<rect x="2" y="15" width="5" height="5" rx="0.5" />
|
|
<rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
|
|
<rect x="17" y="15" width="5" height="5" rx="0.5" />
|
|
{/* Curved arrow jumping over first box */}
|
|
<path d="M4 12 C4 6, 14 6, 14 12" />
|
|
<polyline points="12,10 14,12 12,14" />
|
|
</svg>
|
|
);
|
|
|
|
interface SkipObjectsModalProps {
|
|
printerId: number;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
|
|
const { t } = useTranslation();
|
|
const { showToast } = useToast();
|
|
const { hasPermission } = useAuth();
|
|
const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
|
|
const [enlarged, setEnlarged] = useState(false);
|
|
|
|
const { data: status } = useQuery({
|
|
queryKey: ['printerStatus', printerId],
|
|
queryFn: () => api.getPrinterStatus(printerId),
|
|
refetchInterval: 30000,
|
|
enabled: isOpen,
|
|
});
|
|
|
|
const { data: objectsData, refetch: refetchObjects } = useQuery({
|
|
queryKey: ['printableObjects', printerId],
|
|
queryFn: () => api.getPrintableObjects(printerId),
|
|
enabled: isOpen,
|
|
refetchInterval: isOpen ? 5000 : false,
|
|
});
|
|
|
|
const skipObjectsMutation = useMutation({
|
|
mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
|
|
onSuccess: (data) => {
|
|
showToast(data.message || t('printers.skipObjects.objectsSkipped'));
|
|
setPendingSkip(null);
|
|
refetchObjects();
|
|
},
|
|
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
|
|
});
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
onClick={onClose}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
if (enlarged) setEnlarged(false);
|
|
else onClose();
|
|
}
|
|
}}
|
|
tabIndex={-1}
|
|
ref={(el) => el?.focus()}
|
|
>
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-black/50 z-0" />
|
|
{/* Modal */}
|
|
<div
|
|
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"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
<SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{!objectsData ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
|
|
</div>
|
|
) : objectsData.objects.length === 0 ? (
|
|
<div className="text-center py-8 px-4 text-bambu-gray">
|
|
<p className="text-sm">{t('printers.noObjectsFound')}</p>
|
|
<p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col overflow-hidden">
|
|
{/* Info Banner */}
|
|
<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">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
|
|
<Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
|
|
<p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
|
|
</div>
|
|
<div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
|
|
{objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Layer Warning */}
|
|
{(status?.layer_num ?? 0) <= 1 && (
|
|
<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">
|
|
<AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
|
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
|
{t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content: Image + List side by side */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left: Preview Image with object markers */}
|
|
<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">
|
|
<div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
|
|
{status?.cover_url ? (
|
|
<img
|
|
src={withStreamToken(`${status.cover_url}?view=top`)}
|
|
alt={t('printers.printPreview')}
|
|
className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
|
|
/>
|
|
) : (
|
|
<div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
|
|
<Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
|
|
</div>
|
|
)}
|
|
{/* Enlarge hint */}
|
|
<div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Maximize2 className="w-3.5 h-3.5 text-white" />
|
|
</div>
|
|
{/* Object ID markers overlay - positioned based on object data */}
|
|
{objectsData.objects.length > 0 && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{objectsData.objects.map((obj, idx) => {
|
|
let x: number, y: number;
|
|
|
|
// Use position data if available, otherwise fall back to grid
|
|
if (obj.x != null && obj.y != null && objectsData.bbox_all) {
|
|
// bbox_all defines the visible area in the top_N.png image
|
|
// Format: [x_min, y_min, x_max, y_max] in mm
|
|
const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
|
|
const bboxWidth = xMax - xMin;
|
|
const bboxHeight = yMax - yMin;
|
|
|
|
// The image shows bbox_all area with some padding (~5-10%)
|
|
const padding = 8;
|
|
const contentArea = 100 - (padding * 2);
|
|
|
|
// Map object position to image percentage
|
|
x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
|
|
// Y axis: image Y increases downward, but 3D Y increases toward back
|
|
y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
|
|
|
|
// Clamp to valid range
|
|
x = Math.max(5, Math.min(95, x));
|
|
y = Math.max(5, Math.min(95, y));
|
|
} else if (obj.x != null && obj.y != null) {
|
|
// Fallback: use full build plate (256mm)
|
|
const buildPlate = 256;
|
|
x = (obj.x / buildPlate) * 100;
|
|
y = 100 - (obj.y / buildPlate) * 100;
|
|
x = Math.max(5, Math.min(95, x));
|
|
y = Math.max(5, Math.min(95, y));
|
|
} else {
|
|
// Fallback: arrange in a grid pattern over the build plate area
|
|
const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
|
|
const row = Math.floor(idx / cols);
|
|
const col = idx % cols;
|
|
const rows = Math.ceil(objectsData.objects.length / cols);
|
|
x = 15 + (col * (70 / cols)) + (35 / cols);
|
|
y = 15 + (row * (70 / rows)) + (35 / rows);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={obj.id}
|
|
className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
|
|
obj.skipped
|
|
? 'bg-red-500 text-white line-through'
|
|
: 'bg-bambu-green text-black'
|
|
}`}
|
|
style={{
|
|
left: `${x}%`,
|
|
top: `${y}%`,
|
|
transform: 'translate(-50%, -50%)'
|
|
}}
|
|
title={obj.name}
|
|
>
|
|
{obj.id}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{/* Object count overlay */}
|
|
<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">
|
|
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Object List with prominent IDs */}
|
|
<div className="flex-1 min-w-0 overflow-y-auto">
|
|
{objectsData.objects.map((obj) => (
|
|
<div
|
|
key={obj.id}
|
|
className={`
|
|
flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
|
|
${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
|
|
`}
|
|
>
|
|
{/* Large prominent ID badge */}
|
|
<div className={`
|
|
w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
|
|
${obj.skipped
|
|
? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
|
|
: 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
|
|
`}>
|
|
<span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
|
|
{obj.id}
|
|
</span>
|
|
<span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
|
|
ID
|
|
</span>
|
|
</div>
|
|
|
|
{/* Object name and status */}
|
|
<div className="flex-1 min-w-0">
|
|
<span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
|
|
{obj.name}
|
|
</span>
|
|
{obj.skipped && (
|
|
<span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Skip button */}
|
|
{!obj.skipped ? (
|
|
<button
|
|
onClick={() => setPendingSkip({ id: obj.id, name: obj.name })}
|
|
disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
|
|
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
|
|
(status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
|
|
? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
|
|
: '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'
|
|
}`}
|
|
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'))}
|
|
>
|
|
{t('printers.skipObjects.skip')}
|
|
</button>
|
|
) : (
|
|
<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">
|
|
{t('printers.skipObjects.skipped')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{pendingSkip && (
|
|
<ConfirmModal
|
|
variant="warning"
|
|
title={t('printers.skipObjects.confirmTitle')}
|
|
message={t('printers.skipObjects.confirmMessage', { name: pendingSkip.name })}
|
|
confirmText={t('printers.skipObjects.skip')}
|
|
isLoading={skipObjectsMutation.isPending}
|
|
onConfirm={() => skipObjectsMutation.mutate([pendingSkip.id])}
|
|
onCancel={() => setPendingSkip(null)}
|
|
/>
|
|
)}
|
|
{/* Enlarged lightbox overlay */}
|
|
{enlarged && objectsData && (
|
|
<div
|
|
className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
|
|
onClick={() => setEnlarged(false)}
|
|
>
|
|
<button
|
|
onClick={() => setEnlarged(false)}
|
|
className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
<div
|
|
className="relative max-w-[600px] max-h-[80vh] aspect-square"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{status?.cover_url ? (
|
|
<img
|
|
src={withStreamToken(`${status.cover_url}?view=top`)}
|
|
alt={t('printers.printPreview')}
|
|
className="w-full h-full object-contain rounded-lg bg-gray-900"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
|
|
<Box className="w-16 h-16 text-gray-500" />
|
|
</div>
|
|
)}
|
|
{/* Object ID markers overlay */}
|
|
{objectsData.objects.length > 0 && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{objectsData.objects.map((obj, idx) => {
|
|
let x: number, y: number;
|
|
|
|
if (obj.x != null && obj.y != null && objectsData.bbox_all) {
|
|
const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
|
|
const bboxWidth = xMax - xMin;
|
|
const bboxHeight = yMax - yMin;
|
|
const padding = 8;
|
|
const contentArea = 100 - (padding * 2);
|
|
x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
|
|
y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
|
|
x = Math.max(5, Math.min(95, x));
|
|
y = Math.max(5, Math.min(95, y));
|
|
} else if (obj.x != null && obj.y != null) {
|
|
const buildPlate = 256;
|
|
x = (obj.x / buildPlate) * 100;
|
|
y = 100 - (obj.y / buildPlate) * 100;
|
|
x = Math.max(5, Math.min(95, x));
|
|
y = Math.max(5, Math.min(95, y));
|
|
} else {
|
|
const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
|
|
const row = Math.floor(idx / cols);
|
|
const col = idx % cols;
|
|
const rows = Math.ceil(objectsData.objects.length / cols);
|
|
x = 15 + (col * (70 / cols)) + (35 / cols);
|
|
y = 15 + (row * (70 / rows)) + (35 / rows);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={obj.id}
|
|
className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
|
|
obj.skipped
|
|
? 'bg-red-500 text-white line-through'
|
|
: 'bg-bambu-green text-black'
|
|
}`}
|
|
style={{
|
|
left: `${x}%`,
|
|
top: `${y}%`,
|
|
transform: 'translate(-50%, -50%)'
|
|
}}
|
|
title={obj.name}
|
|
>
|
|
{obj.id}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{/* Active count badge */}
|
|
<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">
|
|
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|