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 }) => (
);
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 (
<>
{
if (e.key === 'Escape') {
if (enlarged) setEnlarged(false);
else onClose();
}
}}
tabIndex={-1}
ref={(el) => el?.focus()}
>
{/* Backdrop */}
{/* Modal */}
e.stopPropagation()}
>
{/* Header */}
{t('printers.skipObjects.title')}
{!objectsData ? (
) : objectsData.objects.length === 0 ? (
{t('printers.noObjectsFound')}
{t('printers.objectsLoadedOnPrintStart')}
) : (
{/* Info Banner */}
{t('printers.skipObjects.matchIdsInfo')}
{t('printers.skipObjects.printerShowsIds')}
{objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
{/* Layer Warning */}
{(status?.layer_num ?? 0) <= 1 && (
{t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
)}
{/* Content: Image + List side by side */}
{/* Left: Preview Image with object markers */}
setEnlarged(true)}>
{status?.cover_url ? (
})
) : (
)}
{/* Enlarge hint */}
{/* Object ID markers overlay - positioned based on object data */}
{objectsData.objects.length > 0 && (
{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 (
{obj.id}
);
})}
)}
{/* Object count overlay */}
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
{/* Right: Object List with prominent IDs */}
{objectsData.objects.map((obj) => (
{/* Large prominent ID badge */}
{obj.id}
ID
{/* Object name and status */}
{obj.name}
{obj.skipped && (
{t('printers.willBeSkipped')}
)}
{/* Skip button */}
{!obj.skipped ? (
) : (
{t('printers.skipObjects.skipped')}
)}
))}
)}
{pendingSkip && (
skipObjectsMutation.mutate([pendingSkip.id])}
onCancel={() => setPendingSkip(null)}
/>
)}
{/* Enlarged lightbox overlay */}
{enlarged && objectsData && (
setEnlarged(false)}
>
e.stopPropagation()}
>
{status?.cover_url ? (
})
) : (
)}
{/* Object ID markers overlay */}
{objectsData.objects.length > 0 && (
{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 (
{obj.id}
);
})}
)}
{/* Active count badge */}
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
)}
>
);
}