mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge remote-tracking branch 'upstream/dev' into feature/billing
This commit is contained in:
@@ -45,6 +45,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
|
||||
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
|
||||
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
|
||||
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
|
||||
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
|
||||
|
||||
// Provider-specific config (scalar fields only — event_priorities is split out
|
||||
@@ -200,6 +201,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
on_stock_break_alert: onStockBreakAlert,
|
||||
on_plate_clear_required: onPlateClearRequired,
|
||||
on_bed_cooled: onBedCooled,
|
||||
on_ha_sensor_alert: onHaSensorAlert,
|
||||
on_first_layer_complete: onFirstLayerComplete,
|
||||
};
|
||||
|
||||
@@ -635,6 +637,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
<span className="text-sm text-white">{t('notifications.offline')}</span>
|
||||
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between col-span-2">
|
||||
<div>
|
||||
<span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
|
||||
<span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
|
||||
</div>
|
||||
<Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white">{t('notifications.error')}</span>
|
||||
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
|
||||
@@ -688,6 +697,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
|
||||
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
|
||||
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
|
||||
if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
|
||||
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
|
||||
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
|
||||
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrintBatch, PrintBatchPlateProgress, Permission } from '../api/client';
|
||||
import { Card } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatDuration, parseUTCDate } from '../utils/date';
|
||||
import { getCurrencySymbol } from '../utils/currency';
|
||||
|
||||
type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
interface BatchOrdersViewProps {
|
||||
hasPermission: (p: Permission) => boolean;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch orders tab (#342).
|
||||
*
|
||||
* An order lives longer than the queue it spawned: once its runs finish they
|
||||
* leave the active queue entirely, so the Queue and History tabs each hold
|
||||
* only half the picture. This is the one place that shows what was asked for
|
||||
* against what has actually been produced — including the runs that failed and
|
||||
* are therefore still owed.
|
||||
*/
|
||||
export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
|
||||
const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
|
||||
const currency = getCurrencySymbol(settings?.currency || 'USD');
|
||||
|
||||
const { data: batches, isLoading } = useQuery({
|
||||
queryKey: ['batches', statusFilter],
|
||||
queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
};
|
||||
|
||||
const dispatchMutation = useMutation({
|
||||
mutationFn: ({ id, plateId }: { id: number; plateId?: number | null }) =>
|
||||
api.dispatchBatch(id, plateId !== undefined ? { plate_id: plateId, only_plate: true } : {}),
|
||||
onSuccess: (batch) => {
|
||||
invalidate();
|
||||
showToast(t('queue.batchOrders.dispatched', { name: batch.name }), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: number) => api.cancelBatch(id),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setCancelTarget(null);
|
||||
showToast(t('queue.batchCancelled'), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
|
||||
const canDispatch = hasPermission('queue:create' as Permission);
|
||||
const canCancel = hasPermission('queue:delete_all' as Permission);
|
||||
|
||||
const filters: StatusFilter[] = ['active', 'completed', 'cancelled', 'all'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white">{t('queue.batchOrders.title')}</h2>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{filters.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
||||
statusFilter === value
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{t(`queue.batchOrders.filter.${value}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
|
||||
) : !batches?.length ? (
|
||||
<Card className="p-12 text-center border-dashed">
|
||||
<Package className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-xl font-medium text-white mb-2">{t('queue.batchOrders.emptyTitle')}</h3>
|
||||
<p className="text-bambu-gray max-w-md mx-auto">{t('queue.batchOrders.emptyDescription')}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{batches.map((batch) => (
|
||||
<BatchOrderCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
currency={currency}
|
||||
canDispatch={canDispatch}
|
||||
canCancel={canCancel}
|
||||
isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
|
||||
onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
|
||||
onCancel={() => setCancelTarget(batch)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cancelTarget && (
|
||||
<ConfirmModal
|
||||
title={t('queue.cancelBatchConfirmTitle')}
|
||||
message={t('queue.cancelBatchConfirmMessage')}
|
||||
confirmText={t('queue.cancelBatch')}
|
||||
variant="warning"
|
||||
onConfirm={() => cancelMutation.mutate(cancelTarget.id)}
|
||||
onCancel={() => setCancelTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
|
||||
completed: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300',
|
||||
cancelled: 'bg-bambu-dark-tertiary text-bambu-gray',
|
||||
};
|
||||
|
||||
function BatchOrderCard({
|
||||
batch,
|
||||
currency,
|
||||
canDispatch,
|
||||
canCancel,
|
||||
isDispatching,
|
||||
onDispatch,
|
||||
onCancel,
|
||||
t,
|
||||
}: {
|
||||
batch: PrintBatch;
|
||||
currency: string;
|
||||
canDispatch: boolean;
|
||||
canCancel: boolean;
|
||||
isDispatching: boolean;
|
||||
onDispatch: (plateId?: number | null) => void;
|
||||
onCancel: () => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
// Progress is measured against the target, not against what was queued —
|
||||
// that is the whole difference between an order and a grouping.
|
||||
const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
|
||||
+ batch.printing_count + batch.failed_count;
|
||||
const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
|
||||
const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
|
||||
const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap items-start gap-3 mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-white font-medium truncate">{batch.name}</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_STYLES[batch.status] ?? STATUS_STYLES.cancelled}`}>
|
||||
{t(`queue.batchOrders.status.${batch.status}`)}
|
||||
</span>
|
||||
{!batch.has_targets && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray"
|
||||
title={t('queue.batchOrders.noTargetsHint')}
|
||||
>
|
||||
{t('queue.batchOrders.noTargets')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{batch.created_by_username
|
||||
? t('queue.addedBy', { name: batch.created_by_username })
|
||||
: null}
|
||||
{dueDate && (
|
||||
<span className={isOverdue ? 'text-orange-600 dark:text-orange-400' : ''}>
|
||||
{batch.created_by_username ? ' • ' : ''}
|
||||
{t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{batch.notes && <p className="text-xs text-bambu-gray mt-1 whitespace-pre-wrap">{batch.notes}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{batch.has_targets && batch.remaining_count > 0 && batch.status !== 'cancelled' && canDispatch && (
|
||||
<Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
|
||||
<PlayCircle className="w-4 h-4 mr-1" />
|
||||
{t('queue.batchOrders.dispatchRemaining', { count: batch.remaining_count })}
|
||||
</Button>
|
||||
)}
|
||||
{batch.status === 'active' && batch.pending_count > 0 && canCancel && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
{t('queue.cancelBatch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
batch.status === 'completed' ? 'bg-bambu-green' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-bambu-gray whitespace-nowrap tabular-nums">
|
||||
{t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray mb-1">
|
||||
{batch.printing_count > 0 && <span>{t('queue.batchOrders.printing', { count: batch.printing_count })}</span>}
|
||||
{batch.pending_count > 0 && <span>{t('queue.batch.pendingCount', { count: batch.pending_count })}</span>}
|
||||
{batch.failed_count > 0 && (
|
||||
<span className="text-orange-600 dark:text-orange-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('queue.batchOrders.failed', { count: batch.failed_count })}
|
||||
</span>
|
||||
)}
|
||||
{batch.has_targets && batch.remaining_count > 0 && (
|
||||
<span>{t('queue.batchOrders.remaining', { count: batch.remaining_count })}</span>
|
||||
)}
|
||||
{batch.print_time_seconds > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDuration(batch.print_time_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{batch.actual_cost != null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Coins className="w-3 h-3" />
|
||||
<span>
|
||||
{t('queue.batchOrders.costSoFar', {
|
||||
amount: `${currency} ${batch.actual_cost.toFixed(2)}`,
|
||||
})}
|
||||
</span>
|
||||
{batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && (
|
||||
<span>
|
||||
{t('queue.batchOrders.costRemaining', {
|
||||
amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.has_targets && batch.plates.length > 0 && (
|
||||
<div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
|
||||
{batch.plates.map((plate) => (
|
||||
<PlateRow
|
||||
key={`${plate.plate_id ?? 'file'}`}
|
||||
plate={plate}
|
||||
batchStatus={batch.status}
|
||||
currency={currency}
|
||||
canDispatch={canDispatch}
|
||||
isDispatching={isDispatching}
|
||||
onDispatch={() => onDispatch(plate.plate_id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlateRow({
|
||||
plate,
|
||||
batchStatus,
|
||||
currency,
|
||||
canDispatch,
|
||||
isDispatching,
|
||||
onDispatch,
|
||||
t,
|
||||
}: {
|
||||
plate: PrintBatchPlateProgress;
|
||||
batchStatus: string;
|
||||
currency: string;
|
||||
canDispatch: boolean;
|
||||
isDispatching: boolean;
|
||||
onDispatch: () => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const label = plate.plate_name
|
||||
|| (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile'));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<Layers className="w-3.5 h-3.5 text-bambu-gray flex-shrink-0" />
|
||||
<span className="text-white min-w-0 truncate">{label}</span>
|
||||
<span className="text-bambu-gray tabular-nums">
|
||||
{t('queue.batchOrders.plateProgress', {
|
||||
completed: plate.completed_count,
|
||||
target: plate.quantity_target,
|
||||
})}
|
||||
</span>
|
||||
{plate.failed_count > 0 && (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{t('queue.batchOrders.failed', { count: plate.failed_count })}
|
||||
</span>
|
||||
)}
|
||||
{plate.actual_cost != null && (
|
||||
<span className="text-bambu-gray tabular-nums">{`${currency} ${plate.actual_cost.toFixed(2)}`}</span>
|
||||
)}
|
||||
{plate.remaining > 0 && batchStatus !== 'cancelled' && (
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<span className="text-bambu-gray">
|
||||
{t('queue.batchOrders.remaining', { count: plate.remaining })}
|
||||
</span>
|
||||
{canDispatch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDispatch}
|
||||
disabled={isDispatching}
|
||||
className="text-bambu-green hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t('queue.batchOrders.dispatchPlate')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
|
||||
import { DiagnosticChecklist } from './ConnectionDiagnostic';
|
||||
import { SystemHealthPanel } from './SystemHealthPanel';
|
||||
import { Collapsible } from './Collapsible';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
|
||||
|
||||
@@ -47,9 +48,36 @@ function formatElapsed(seconds: number): string {
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function BugReportBubble() {
|
||||
interface BugReportBubbleProps {
|
||||
/**
|
||||
* Render the floating disc in the bottom-right corner. False when the
|
||||
* trigger lives somewhere else — the compact header does this (#2750), so
|
||||
* the panel still mounts here while the button that opens it sits in the
|
||||
* header. The panel deliberately stays at the Layout root rather than
|
||||
* moving into the header with its button: the header is a ``fixed z-40``
|
||||
* element and therefore its own stacking context, so a ``z-50`` panel
|
||||
* nested inside it would be capped at the header's level and end up
|
||||
* underneath every ordinary z-50 modal in the app.
|
||||
*/
|
||||
showTrigger?: boolean;
|
||||
/** Controlled open state. Falls back to internal state when omitted. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugReportBubbleProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
const setIsOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
const [viewState, setViewState] = useState<ViewState>('form');
|
||||
const [description, setDescription] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -109,8 +137,12 @@ export function BugReportBubble() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [viewState, elapsedSeconds]);
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
// Reset on open rather than in the click handler: the panel now has two
|
||||
// possible triggers (the floating disc here, and the compact header's button
|
||||
// which only flips the controlled flag), and a stale half-filled form
|
||||
// reappearing for one of them would be a nasty little inconsistency.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setViewState('form');
|
||||
setDescription('');
|
||||
setEmail('');
|
||||
@@ -120,7 +152,9 @@ export function BugReportBubble() {
|
||||
setErrorMessage('');
|
||||
setElapsedSeconds(0);
|
||||
setWasDebug(false);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
@@ -216,25 +250,47 @@ export function BugReportBubble() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating bubble */}
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
||||
title={t('bugReport.title')}
|
||||
>
|
||||
<Bug className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating bubble. Absent below the sidebar-compact breakpoint, where
|
||||
the compact header carries the trigger instead — see Layout. */}
|
||||
{showTrigger && (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
||||
title={t('bugReport.title')}
|
||||
>
|
||||
<Bug className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Slide-in panel anchored to bottom-right */}
|
||||
{/* Slide-in panel anchored to bottom-right; a bottom sheet on phones.
|
||||
The desktop geometry cannot be reused there: `w-full` resolves against
|
||||
the viewport for a fixed element, so on a 375px screen the panel was
|
||||
375px wide and then pushed 16px in from the right, putting its left
|
||||
edge at -16px and cutting a strip of the form off-screen. `max-w-md`
|
||||
hid this on anything above ~464px wide. */}
|
||||
{isOpen && (
|
||||
<div
|
||||
id="bug-report-modal"
|
||||
className="fixed bottom-20 right-4 z-50 w-full max-w-md"
|
||||
className={
|
||||
isMobile
|
||||
? 'fixed inset-x-0 bottom-0 z-50'
|
||||
: showTrigger
|
||||
? 'fixed bottom-20 right-4 z-50 w-full max-w-md'
|
||||
// Trigger is in the compact header, so anchor under it rather
|
||||
// than to a corner the user did not touch. Only reachable
|
||||
// between the mobile and sidebar-compact breakpoints — below
|
||||
// that it is a bottom sheet, above it the disc is back.
|
||||
: 'fixed top-16 right-4 z-50 w-full max-w-md'
|
||||
}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto"
|
||||
className={`bg-white dark:bg-gray-800 shadow-2xl border border-gray-200 dark:border-gray-700 overflow-y-auto ${
|
||||
isMobile
|
||||
? 'rounded-t-2xl max-h-[85vh] pb-[env(safe-area-inset-bottom)]'
|
||||
: 'rounded-lg max-h-[80vh]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { api } from '../api/client';
|
||||
import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
|
||||
import type { PrintQueueItem, Permission } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { queueItemDisplayName } from '../utils/queueItemName';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
completed: { icon: CheckCircle, color: 'text-emerald-600 dark:text-emerald-400', border: 'border-l-emerald-500' },
|
||||
@@ -54,7 +55,7 @@ export function CompactHistoryRow({
|
||||
}) {
|
||||
const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;
|
||||
const StatusIcon = config.icon;
|
||||
const displayName = item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`;
|
||||
const displayName = queueItemDisplayName(item);
|
||||
|
||||
const thumbnailUrl = item.archive_thumbnail
|
||||
? api.getArchiveThumbnail(item.archive_id!)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
|
||||
import { toFilamentId } from './spool-form/utils';
|
||||
import { Button } from './Button';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||||
|
||||
interface SlotInfo {
|
||||
amsId: number;
|
||||
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showExtendedColors, setShowExtendedColors] = useState(false);
|
||||
const scrolledToRef = useRef<string>('');
|
||||
// The success state is held briefly before the modal closes itself; that
|
||||
// timer must not outlive the modal.
|
||||
const { schedule: scheduleClose } = useCancellableTimeout();
|
||||
|
||||
// Fetch cloud settings (gracefully handle 401 when logged out)
|
||||
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
|
||||
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
// Close after showing success briefly
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
|
||||
onSuccess: () => {
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
/** i18n key for the resting tooltip. */
|
||||
titleKey?: string;
|
||||
/** i18n key for the tooltip while the tick is showing. */
|
||||
copiedTitleKey?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy-to-clipboard button with the plain-HTTP fallback (#1174).
|
||||
*
|
||||
* Lifted out of PrinterInfoModal when the Docker update instructions needed
|
||||
* the same control (#2664). The fallback is the whole reason this is shared
|
||||
* rather than re-written per call site: navigator.clipboard is gated behind
|
||||
* the secure-context requirement, so on a LAN install reached over plain HTTP
|
||||
* — which is most Bambuddy installs — the API is simply undefined, and a
|
||||
* naive implementation swallows the failure with no tick and nothing copied.
|
||||
*/
|
||||
export function CopyButton({
|
||||
value,
|
||||
titleKey = 'printers.copyToClipboard',
|
||||
copiedTitleKey = 'printers.copied',
|
||||
className = 'ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors',
|
||||
iconClassName = 'w-3.5 h-3.5',
|
||||
}: CopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
// Legacy execCommand path via an off-screen textarea, matching the
|
||||
// pattern used by CameraTokensPage's plaintext-token modal.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
if (!ok) return;
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Both paths failed (no clipboard API, no execCommand). Leave the icon
|
||||
// unchanged so the user knows nothing was copied.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
title={copied ? t(copiedTitleKey) : t(titleKey)}
|
||||
>
|
||||
{copied ? <Check className={`${iconClassName} text-bambu-green`} /> : <Copy className={iconClassName} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
LocalBackupPathCheck,
|
||||
LocalBackupStatus,
|
||||
ScheduleType,
|
||||
CloudAccountCounts,
|
||||
CloudAuthStatus,
|
||||
Printer,
|
||||
} from '../api/client';
|
||||
@@ -324,6 +325,24 @@ export function GitHubBackupSettings() {
|
||||
queryFn: api.getCloudStatus,
|
||||
});
|
||||
|
||||
// How many cloud accounts the backup would actually collect from, across
|
||||
// both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`,
|
||||
// which is only *this viewer's* Bambu sign-in: with auth enabled every user
|
||||
// holds their own credentials and the backup collects from all of them, so
|
||||
// an admin who never signed in to Bambu Cloud personally would otherwise see
|
||||
// the category disabled while there is plenty to back up (#2717).
|
||||
const { data: cloudAccounts } = useQuery<CloudAccountCounts>({
|
||||
queryKey: ['github-backup-cloud-accounts'],
|
||||
queryFn: api.getGitHubBackupCloudAccounts,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0);
|
||||
// Until the count arrives, fall back to the viewer's own Bambu status rather
|
||||
// than rendering the toggle as unavailable and making it flicker enabled.
|
||||
const anyCloudConnected = cloudAccounts
|
||||
? connectedCloudAccounts > 0
|
||||
: !!cloudStatus?.is_authenticated;
|
||||
|
||||
// Fetch printers and their statuses for K-profile availability
|
||||
const { data: printers } = useQuery<Printer[]>({
|
||||
queryKey: ['printers'],
|
||||
@@ -751,18 +770,18 @@ export function GitHubBackupSettings() {
|
||||
<p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||
<label className={`flex items-start gap-2 ${!anyCloudConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backupCloudProfiles}
|
||||
onChange={(e) => setBackupCloudProfiles(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
disabled={!cloudStatus?.is_authenticated}
|
||||
disabled={!anyCloudConnected}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
|
||||
{!cloudStatus?.is_authenticated && (
|
||||
<span className={`text-sm ${anyCloudConnected ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
|
||||
{!anyCloudConnected && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('backup.cloudLoginRequiredShort')}
|
||||
@@ -770,6 +789,18 @@ export function GitHubBackupSettings() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
|
||||
{/* Say how many accounts are in scope. On a multi-user
|
||||
install the presets being backed up are other people's,
|
||||
and the count is the only honest way to show that
|
||||
without naming them. */}
|
||||
{connectedCloudAccounts > 0 && (
|
||||
<p className="text-xs text-bambu-gray mt-0.5">
|
||||
{t('backup.cloudProfilesAccounts', {
|
||||
bambu: cloudAccounts?.bambu ?? 0,
|
||||
orca: cloudAccounts?.orca ?? 0,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Gauge, Loader2, Save, Search, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
/**
|
||||
* Bind a Home Assistant entity to a printer, or edit an existing binding
|
||||
* (#1148, #448).
|
||||
*
|
||||
* The entity picker is the load-bearing part: kind, device_class and unit all
|
||||
* come from the entity rather than from the user, because getting any of them
|
||||
* wrong is a validation error from the backend that nobody could act on.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
sensor?: PrinterHASensor | null;
|
||||
printers: Printer[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The alert wording follows the device class, so a door offers "Open" rather
|
||||
// than "On". Shared with PrinterHASensorRow's rendering of the same classes.
|
||||
const ALERT_LABEL_KEYS: Record<string, { on: string; off: string }> = {
|
||||
door: { on: 'open', off: 'closed' },
|
||||
garage_door: { on: 'open', off: 'closed' },
|
||||
window: { on: 'open', off: 'closed' },
|
||||
opening: { on: 'open', off: 'closed' },
|
||||
lock: { on: 'unlocked', off: 'locked' },
|
||||
motion: { on: 'detected', off: 'clear' },
|
||||
occupancy: { on: 'detected', off: 'clear' },
|
||||
presence: { on: 'detected', off: 'clear' },
|
||||
smoke: { on: 'detected', off: 'clear' },
|
||||
gas: { on: 'detected', off: 'clear' },
|
||||
moisture: { on: 'wet', off: 'dry' },
|
||||
problem: { on: 'problem', off: 'ok' },
|
||||
safety: { on: 'problem', off: 'ok' },
|
||||
running: { on: 'running', off: 'stopped' },
|
||||
};
|
||||
|
||||
export function HASensorModal({ sensor, printers, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isEditing = !!sensor;
|
||||
|
||||
const [printerId, setPrinterId] = useState<number | ''>(sensor?.printer_id ?? printers[0]?.id ?? '');
|
||||
const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
|
||||
const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary');
|
||||
const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
|
||||
const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
|
||||
const [name, setName] = useState(sensor?.name ?? '');
|
||||
const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
|
||||
const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
|
||||
const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
|
||||
const [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true);
|
||||
const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
|
||||
const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Same gate and the same wording as AddSmartPlugModal: without a configured
|
||||
// Home Assistant the picker can only return an error, so say why up front
|
||||
// instead of showing an empty list.
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
|
||||
|
||||
const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
|
||||
queryKey: ['bindableHAEntities'],
|
||||
queryFn: () => api.getBindableHAEntities(),
|
||||
enabled: haConfigured,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
const all = entities ?? [];
|
||||
if (!needle) return all;
|
||||
return all.filter(
|
||||
(e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle)
|
||||
);
|
||||
}, [entities, search]);
|
||||
|
||||
const selectEntity = (entity: HADisplayEntity) => {
|
||||
setEntityId(entity.entity_id);
|
||||
setDeviceClass(entity.device_class);
|
||||
setUnit(entity.unit_of_measurement);
|
||||
const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
|
||||
setKind(nextKind);
|
||||
// Switching kind strands the other kind's alert fields, and the backend
|
||||
// rejects a numeric sensor that still carries an alert_state.
|
||||
if (nextKind === 'numeric') setAlertState('');
|
||||
else {
|
||||
setAlertAbove('');
|
||||
setAlertBelow('');
|
||||
}
|
||||
// Sliced to the column width: Home Assistant friendly names have no length
|
||||
// limit, and a long one would come back as a Pydantic error on a field the
|
||||
// user did not type into.
|
||||
if (!name.trim()) setName(entity.friendly_name.slice(0, 100));
|
||||
};
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['haSensors'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] });
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
entity_id: entityId,
|
||||
kind,
|
||||
device_class: deviceClass,
|
||||
unit,
|
||||
alert_state: kind === 'binary' && alertState ? alertState : null,
|
||||
alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null,
|
||||
alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
|
||||
block_print: blockPrint,
|
||||
notify_on_alert: notifyOnAlert,
|
||||
show_on_printer_card: showOnCard,
|
||||
};
|
||||
return isEditing
|
||||
? api.updateHASensor(sensor.id, payload)
|
||||
: api.createHASensor({ ...payload, printer_id: Number(printerId) });
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => setError(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.deleteHASensor(sensor!.id),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
showToast(t('haSensors.toast.deleted'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => setError(err.message),
|
||||
});
|
||||
|
||||
const hasAlertCondition =
|
||||
kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!entityId) return setError(t('haSensors.error.pickEntity'));
|
||||
if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
|
||||
if (printerId === '') return setError(t('haSensors.error.printerRequired'));
|
||||
// Mirrors the backend rule, so the user is told before the round trip
|
||||
// rather than by a 422.
|
||||
if ((blockPrint || notifyOnAlert) && !hasAlertCondition) {
|
||||
return setError(t('haSensors.error.alertRequired'));
|
||||
}
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? ''];
|
||||
const stateLabel = (which: 'on' | 'off') => {
|
||||
const key = alertLabels?.[which] ?? which;
|
||||
return t(`haSensors.states.${key}`, { defaultValue: key });
|
||||
};
|
||||
|
||||
const isPending = saveMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
<Gauge className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
|
||||
<select
|
||||
value={printerId}
|
||||
onChange={(e) => setPrinterId(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
>
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!haConfigured && (
|
||||
<div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
|
||||
{t('smartPlugs.haNotConfigured')}{' '}
|
||||
<span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
|
||||
{t('haSensors.entity')}
|
||||
</label>
|
||||
{entitiesError && (
|
||||
<div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||
{(entitiesError as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('haSensors.searchPlaceholder')}
|
||||
disabled={!haConfigured}
|
||||
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
|
||||
haConfigured ? '' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
{!haConfigured && (
|
||||
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
|
||||
)}
|
||||
{haConfigured && entitiesLoading && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{haConfigured && !entitiesLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
|
||||
)}
|
||||
{haConfigured &&
|
||||
!entitiesLoading &&
|
||||
filtered.map((entity) => (
|
||||
<button
|
||||
key={entity.entity_id}
|
||||
type="button"
|
||||
onClick={() => selectEntity(entity)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
entity.entity_id === entityId
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{entity.friendly_name}</div>
|
||||
<div className="text-xs text-bambu-gray">
|
||||
{entity.entity_id}
|
||||
{entity.state !== null && ` — ${entity.state}`}
|
||||
{entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
|
||||
{kind === 'binary' ? (
|
||||
<select
|
||||
value={alertState}
|
||||
onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
>
|
||||
<option value="">{t('haSensors.alertNever')}</option>
|
||||
<option value="on">{stateLabel('on')}</option>
|
||||
<option value="off">{stateLabel('off')}</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="block text-xs text-bambu-gray mb-1">
|
||||
{t('haSensors.alertAbove')} {unit ?? ''}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={alertAbove}
|
||||
onChange={(e) => setAlertAbove(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-bambu-gray mb-1">
|
||||
{t('haSensors.alertBelow')} {unit ?? ''}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={alertBelow}
|
||||
onChange={(e) => setAlertBelow(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnCard}
|
||||
onChange={(e) => setShowOnCard(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-white">{t('haSensors.showOnCard')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyOnAlert}
|
||||
onChange={(e) => setNotifyOnAlert(e.target.checked)}
|
||||
disabled={!hasAlertCondition}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{t('haSensors.notifyOnAlert')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blockPrint}
|
||||
onChange={(e) => setBlockPrint(e.target.checked)}
|
||||
disabled={!hasAlertCondition}
|
||||
className="w-4 h-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{t('haSensors.blockPrint')}
|
||||
</span>
|
||||
<span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{isEditing ? (
|
||||
<Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{/* An unconfigured Home Assistant leaves nothing to bind to.
|
||||
Editing an existing sensor still saves — its alert rule and
|
||||
card visibility are Bambuddy's own settings and do not need
|
||||
Home Assistant to be reachable to change. */}
|
||||
<Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,10 +21,17 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
|
||||
import {
|
||||
buildFilamentPresetOptions,
|
||||
resolveFilamentId,
|
||||
type FilamentPresetOption,
|
||||
type FilamentPresetSource,
|
||||
} from '../utils/filamentPresets';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||||
|
||||
interface KProfileCardProps {
|
||||
profile: KProfile;
|
||||
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
|
||||
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
||||
};
|
||||
|
||||
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
|
||||
const getFlowTypeLabel = (nozzleId: string) => {
|
||||
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
|
||||
return 'S'; // Standard Flow (default)
|
||||
};
|
||||
// nozzle_id encodes the flow type, per the slicer's own generator:
|
||||
// "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
|
||||
// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
|
||||
// not a material code.
|
||||
const STANDARD_FLOW = 'HS00';
|
||||
const HIGH_FLOW = 'HH00';
|
||||
|
||||
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
|
||||
// Many printers omit nozzle_id from their extrusion_cali_get response entirely
|
||||
// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
|
||||
// Standard (its parser falls back to nvtStandard when the key is absent), and
|
||||
// so do we: the flow type stays a real, editable value rather than a blank.
|
||||
const getNozzleTypePrefix = (nozzleId: string) => {
|
||||
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
||||
return match ? match[1] : 'HH00';
|
||||
return match ? match[1] : STANDARD_FLOW;
|
||||
};
|
||||
|
||||
// Short label for the profile list.
|
||||
const getFlowTypeLabel = (nozzleId: string) =>
|
||||
getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
|
||||
|
||||
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
||||
const extractFilamentName = (profileName: string) => {
|
||||
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
||||
@@ -149,9 +164,11 @@ interface KProfileModalProps {
|
||||
profile?: KProfile;
|
||||
printerId: number;
|
||||
nozzleDiameter: string;
|
||||
existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
||||
existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
|
||||
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
|
||||
filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
|
||||
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
||||
supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
|
||||
initialNote?: string; // Initial note value for the profile
|
||||
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
||||
onClose: () => void;
|
||||
@@ -166,7 +183,9 @@ function KProfileModal({
|
||||
nozzleDiameter,
|
||||
existingProfiles = [],
|
||||
builtinFilaments = [],
|
||||
filamentPresets = [],
|
||||
isDualNozzle = false,
|
||||
supportsFlowType = true,
|
||||
initialNote = '',
|
||||
initialNoteKey = null,
|
||||
onClose,
|
||||
@@ -181,10 +200,19 @@ function KProfileModal({
|
||||
const [kValue, setKValue] = useState(
|
||||
profile?.k_value ? truncateK(profile.k_value) : '0.020'
|
||||
);
|
||||
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
|
||||
// What the Filament select is bound to. When editing, the printer's own
|
||||
// filament_id (the select is read-only). For a new profile, the *preset
|
||||
// handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
|
||||
// setting_id or a builtin filament id — which is resolved to a real
|
||||
// filament_id on submit, since only some tiers carry one directly.
|
||||
const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
|
||||
// Split nozzle into type and diameter
|
||||
// Both selects are read-only while editing: they report what the printer
|
||||
// holds, they don't set it. '' means the printer reported no nozzle_id, which
|
||||
// single-nozzle models never do (#1748) — showing "High Flow" there was the
|
||||
// UI inventing a value the printer never sent.
|
||||
const [nozzleType, setNozzleType] = useState(
|
||||
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
|
||||
profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
|
||||
);
|
||||
const [modalDiameter, setModalDiameter] = useState(
|
||||
profile?.nozzle_diameter || nozzleDiameter
|
||||
@@ -197,40 +225,49 @@ function KProfileModal({
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
||||
const [note, setNote] = useState(initialNote);
|
||||
const [filamentQuery, setFilamentQuery] = useState('');
|
||||
// The modal defers its own close so the printer has time to process the
|
||||
// command; that timer must not outlive the modal.
|
||||
const { schedule: scheduleClose } = useCancellableTimeout();
|
||||
|
||||
// Extract unique filaments from existing K-profiles on the printer
|
||||
// Use builtin filament table for accurate name resolution (filament_id → name)
|
||||
// Falls back to extracting from profile name for custom/unknown presets
|
||||
const knownFilaments = React.useMemo(() => {
|
||||
// Build lookup map from builtin filament names (includes cloud presets from parent)
|
||||
const builtinMap = new Map<string, string>();
|
||||
for (const bf of builtinFilaments) {
|
||||
builtinMap.set(bf.filament_id, bf.name);
|
||||
}
|
||||
// Name for the filament an existing profile is bound to. The builtin table
|
||||
// (which the parent has already merged with the user's cloud presets) is
|
||||
// authoritative; a profile whose filament_id is in neither falls back to the
|
||||
// name the printer stored for it.
|
||||
const editedFilamentName = React.useMemo(() => {
|
||||
if (!profile?.filament_id) return '';
|
||||
const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
|
||||
if (builtinName) return builtinName;
|
||||
const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
|
||||
return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
|
||||
}, [profile, existingProfiles, builtinFilaments]);
|
||||
|
||||
const filamentMap = new Map<string, { id: string; name: string }>();
|
||||
for (const p of existingProfiles) {
|
||||
if (p.filament_id && !filamentMap.has(p.filament_id)) {
|
||||
// Prefer builtin name (accurate), fall back to extracting from profile name
|
||||
const builtinName = builtinMap.get(p.filament_id);
|
||||
const filamentName = builtinName || extractFilamentName(p.name || '');
|
||||
filamentMap.set(p.filament_id, {
|
||||
id: p.filament_id,
|
||||
name: filamentName || p.filament_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(filamentMap.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}, [existingProfiles, builtinFilaments]);
|
||||
// The tiered list, grouped for rendering. Order is fixed app-wide —
|
||||
// imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
|
||||
// and buildFilamentPresetOptions has already sorted by it, so grouping is
|
||||
// just a partition that preserves that order.
|
||||
const presetGroups = React.useMemo(() => {
|
||||
const labels: [FilamentPresetSource, string][] = [
|
||||
['local', t('kProfiles.modal.source.local')],
|
||||
['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
|
||||
['cloud', t('kProfiles.modal.source.bambuCloud')],
|
||||
['builtin', t('kProfiles.modal.source.builtin')],
|
||||
];
|
||||
const query = filamentQuery.trim().toLowerCase();
|
||||
const matches = query
|
||||
? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
|
||||
: filamentPresets;
|
||||
return labels
|
||||
.map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
|
||||
.filter(g => g.items.length > 0);
|
||||
}, [filamentPresets, filamentQuery, t]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: KProfileCreate) => {
|
||||
console.log('[KProfile] Calling API...');
|
||||
return api.setKProfile(printerId, data);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
onSuccess: (result, variables) => {
|
||||
console.log('[KProfile] Save success:', result);
|
||||
showToast(t('kProfiles.toast.profileSaved'));
|
||||
// Save note if it changed (including clearing it)
|
||||
@@ -243,8 +280,10 @@ function KProfileModal({
|
||||
// Editing: use setting_id if available, or composite key with slot_id
|
||||
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
|
||||
} else {
|
||||
// New profile: use name as key (will be matched when profile is loaded)
|
||||
profileKey = `name_${name}_${filamentId}`;
|
||||
// New profile: use name as key (matched against the reloaded profile,
|
||||
// so it has to be the resolved filament_id that was sent — not the
|
||||
// preset handle the user picked).
|
||||
profileKey = `name_${name}_${variables.filament_id}`;
|
||||
}
|
||||
onSaveNote(profileKey, note);
|
||||
}
|
||||
@@ -252,7 +291,7 @@ function KProfileModal({
|
||||
setIsSyncing(true);
|
||||
// Add delay before closing to give printer time to process the save
|
||||
// onSave will trigger refetch in the parent component
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
onSave();
|
||||
}, 2500);
|
||||
@@ -276,7 +315,7 @@ function KProfileModal({
|
||||
setIsSyncing(true);
|
||||
// Add longer delay for delete - printer needs more time to process
|
||||
// before it can return the updated profile list
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
onClose();
|
||||
}, 4000);
|
||||
@@ -316,14 +355,46 @@ function KProfileModal({
|
||||
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
|
||||
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
||||
|
||||
// An edit is delete + re-add on single-nozzle printers, so the nozzle
|
||||
// fields have to survive the round trip — both selects are disabled while
|
||||
// editing. Rebuilding them blindly from the selects is what let a 0.6mm
|
||||
// profile come back as "HH00-0.4" once the parse defaults had stamped it
|
||||
// 0.4 (#1748), so prefer whatever the printer reported. Where it reported
|
||||
// no nozzle_id at all, send the rebuilt one rather than an empty string —
|
||||
// the field is part of the profile's identity on the wire and the slicer
|
||||
// always populates it.
|
||||
const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
|
||||
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
|
||||
|
||||
// The printer indexes its calibration table by filament_id, so the preset
|
||||
// the user picked has to be reduced to one before anything is sent. Only
|
||||
// the builtin tier and Bambu's official cloud presets carry one outright;
|
||||
// a cloud *user* preset needs its detail fetched, and imported / Orca
|
||||
// presets have no Bambu id at all and map to the generic for their
|
||||
// material. Refuse rather than guess when nothing resolves — a profile
|
||||
// filed under the wrong filament is invisible to the slot that needs it.
|
||||
let resolvedFilamentId = profile?.filament_id || '';
|
||||
if (!profile) {
|
||||
const picked = filamentPresets.find(p => p.id === filamentChoice);
|
||||
if (!picked) {
|
||||
showToast(t('kProfiles.toast.selectFilament'), 'error');
|
||||
return;
|
||||
}
|
||||
resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
|
||||
if (!resolvedFilamentId) {
|
||||
showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For editing or single extruder: just save one profile
|
||||
if (profile || selectedExtruders.length === 1) {
|
||||
const payload = {
|
||||
name: name,
|
||||
k_value: formattedKValue,
|
||||
filament_id: filamentId,
|
||||
nozzle_id: nozzleId,
|
||||
nozzle_diameter: modalDiameter,
|
||||
filament_id: resolvedFilamentId,
|
||||
nozzle_id: editNozzleId,
|
||||
nozzle_diameter: editDiameter,
|
||||
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
|
||||
setting_id: profile?.setting_id,
|
||||
slot_id: profile?.slot_id ?? 0,
|
||||
@@ -341,7 +412,7 @@ function KProfileModal({
|
||||
const batchPayload = selectedExtruders.map(extruderId => ({
|
||||
name: name,
|
||||
k_value: formattedKValue,
|
||||
filament_id: filamentId,
|
||||
filament_id: resolvedFilamentId,
|
||||
nozzle_id: nozzleId,
|
||||
nozzle_diameter: modalDiameter,
|
||||
extruder_id: extruderId,
|
||||
@@ -356,7 +427,7 @@ function KProfileModal({
|
||||
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
|
||||
// Save note for new batch profiles
|
||||
if (onSaveNote && note) {
|
||||
const profileKey = `name_${name}_${filamentId}`;
|
||||
const profileKey = `name_${name}_${resolvedFilamentId}`;
|
||||
onSaveNote(profileKey, note);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -370,7 +441,7 @@ function KProfileModal({
|
||||
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
|
||||
// Wait for final sync before closing
|
||||
// onSave will trigger refetch in the parent component
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
setSavingProgress({ current: 0, total: 0 });
|
||||
onSave();
|
||||
@@ -454,49 +525,77 @@ function KProfileModal({
|
||||
{/* Filament - read-only when editing */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
|
||||
<select
|
||||
value={filamentId}
|
||||
onChange={(e) => {
|
||||
const newFilamentId = e.target.value;
|
||||
setFilamentId(newFilamentId);
|
||||
// Auto-generate profile name when filament is selected (for new profiles)
|
||||
// Only auto-generate if name is empty - don't overwrite user input
|
||||
if (!profile && newFilamentId && !name) {
|
||||
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
|
||||
if (selectedFilament) {
|
||||
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${selectedFilament.name}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!!profile}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
required={!profile}
|
||||
>
|
||||
<option value="">{t('kProfiles.modal.selectFilament')}</option>
|
||||
{/* Show current filament when editing - look up from knownFilaments */}
|
||||
{profile?.filament_id && (
|
||||
<option key={profile.filament_id} value={profile.filament_id}>
|
||||
{knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
|
||||
</option>
|
||||
)}
|
||||
{/* Show known filaments from existing K-profiles (for new profiles) */}
|
||||
{!profile && knownFilaments.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!profile && knownFilaments.length === 0 && (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('kProfiles.modal.noFilamentsHelp')}
|
||||
</p>
|
||||
{profile ? (
|
||||
// Editing or copying: the filament is fixed, so this is a
|
||||
// readout rather than a control.
|
||||
<div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
|
||||
{editedFilamentName || profile.filament_id}
|
||||
</div>
|
||||
) : (
|
||||
// A real list rather than a <select>: Chrome ignores almost
|
||||
// every CSS property on <optgroup>, so a source heading inside
|
||||
// a native dropdown can't be made to stand out.
|
||||
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<div className="relative border-b border-bambu-dark-tertiary">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={filamentQuery}
|
||||
onChange={(e) => setFilamentQuery(e.target.value)}
|
||||
placeholder={t('kProfiles.modal.searchFilaments')}
|
||||
className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto bg-bambu-dark">
|
||||
{presetGroups.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs text-bambu-gray">
|
||||
{filamentPresets.length === 0
|
||||
? t('kProfiles.modal.noFilamentsHelp')
|
||||
: t('kProfiles.modal.noFilamentMatches')}
|
||||
</p>
|
||||
) : presetGroups.map((group) => (
|
||||
<div key={group.source}>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-bambu-gray">{group.items.length}</span>
|
||||
</div>
|
||||
{group.items.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilamentChoice(f.id);
|
||||
// Auto-generate the profile name, but never over
|
||||
// an entry the user typed.
|
||||
if (!name) {
|
||||
const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${f.name}`);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
|
||||
filamentChoice === f.id
|
||||
? 'bg-bambu-green/20 text-white'
|
||||
: 'text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flow Type and Nozzle Size - read-only when editing */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
{/* Flow Type and Nozzle Size - read-only when editing. Flow type
|
||||
is hidden on models sold with a single nozzle variant (the
|
||||
A-series), where the choice would be meaningless — same gate
|
||||
the slicer applies via support_nozzle_volume(). */}
|
||||
<div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
|
||||
<div className={supportsFlowType ? '' : 'hidden'}>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
||||
<select
|
||||
value={nozzleType}
|
||||
@@ -505,10 +604,10 @@ function KProfileModal({
|
||||
setNozzleType(newNozzleType);
|
||||
// Update profile name when flow type changes (for new profiles)
|
||||
// Only auto-generate if name is empty - don't overwrite user input
|
||||
if (!profile && filamentId && !name) {
|
||||
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
||||
if (!profile && filamentChoice && !name) {
|
||||
const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
|
||||
if (selectedFilament) {
|
||||
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
|
||||
const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${selectedFilament.name}`);
|
||||
}
|
||||
}
|
||||
@@ -516,8 +615,8 @@ function KProfileModal({
|
||||
disabled={!!profile}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value="HS00">{t('kProfiles.modal.standard')}</option>
|
||||
<option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -772,13 +871,12 @@ export function KProfilesView() {
|
||||
refetchOnMount: 'always', // Always refetch when component mounts
|
||||
});
|
||||
|
||||
// Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
|
||||
const { data: allProfiles } = useQuery({
|
||||
queryKey: ['kprofiles', selectedPrinter, '0.4'],
|
||||
queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
|
||||
enabled: !!selectedPrinter,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
});
|
||||
// A second fetch for 0.4mm profiles used to seed the Add-Profile filament
|
||||
// dropdown. The dropdown is built from the filament preset tiers now
|
||||
// (#2719), so the round trip bought nothing — and it fired concurrently
|
||||
// with the fetch above whenever a different nozzle was selected, which is
|
||||
// exactly the two-requests-in-flight case that made K-profile fetches time
|
||||
// out (#1748).
|
||||
|
||||
// Fetch builtin filament names for accurate filament_id → name resolution
|
||||
const { data: builtinFilaments } = useQuery({
|
||||
@@ -794,6 +892,28 @@ export function KProfilesView() {
|
||||
staleTime: 300000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// The other three filament tiers, so a printer with no K-profiles yet can
|
||||
// still be given its first one (#2719). Each query stands alone and fails
|
||||
// quietly: not being signed in to a cloud should thin the list, not break
|
||||
// the page, and the builtin tier above guarantees it is never empty.
|
||||
const { data: localPresets } = useQuery({
|
||||
queryKey: ['localPresets'],
|
||||
queryFn: () => api.getLocalPresets(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: orcaCloudList } = useQuery({
|
||||
queryKey: ['orcaCloudProfilesForKProfiles'],
|
||||
queryFn: () => api.orcaCloudListProfiles(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: cloudSettings } = useQuery({
|
||||
queryKey: ['cloudSettings'],
|
||||
queryFn: () => api.getCloudSettings(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch K-profile notes (stored locally)
|
||||
const {
|
||||
data: notesData,
|
||||
@@ -860,6 +980,19 @@ export function KProfilesView() {
|
||||
}));
|
||||
}, [builtinFilamentMap]);
|
||||
|
||||
// Every filament this install knows about, ranked in the app-wide order:
|
||||
// imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
|
||||
// built-in table as the floor.
|
||||
const filamentPresets = React.useMemo(
|
||||
() => buildFilamentPresetOptions({
|
||||
localPresets: localPresets?.filament,
|
||||
orcaProfiles: orcaCloudList?.filament,
|
||||
cloudSettings: cloudSettings?.filament,
|
||||
builtinFilaments,
|
||||
}),
|
||||
[localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
|
||||
);
|
||||
|
||||
// Resolve filament name: builtin table first, then extract from profile name
|
||||
const resolveFilamentName = React.useCallback((profile: KProfile) => {
|
||||
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
|
||||
@@ -911,6 +1044,18 @@ export function KProfilesView() {
|
||||
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
||||
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
||||
|
||||
// Whether this printer model is sold with both Standard and High Flow
|
||||
// nozzles. Comes from the model, not from whether the payload happened to
|
||||
// carry a nozzle_id — most printers omit that field entirely (#1748) while
|
||||
// still offering both flows. Only the A-series has a single variant.
|
||||
const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
|
||||
|
||||
// Don't strand the list behind a filter whose control just disappeared.
|
||||
useEffect(() => {
|
||||
if (!supportsFlowType) setFlowTypeFilter('all');
|
||||
}, [supportsFlowType]);
|
||||
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
|
||||
name: p.name,
|
||||
k_value: parseFloat(p.k_value).toFixed(6),
|
||||
filament_id: p.filament_id,
|
||||
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
|
||||
// An export from a printer that reports no nozzle_id carries
|
||||
// none; fall back to Standard, the same default the slicer's
|
||||
// parser uses for a missing field.
|
||||
nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
|
||||
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
|
||||
extruder_id: p.extruder_id ?? 0,
|
||||
slot_id: 0, // Always create new
|
||||
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={flowTypeFilter}
|
||||
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="all">{t('kProfiles.allFlow')}</option>
|
||||
<option value="hf">{t('kProfiles.hfOnly')}</option>
|
||||
<option value="s">{t('kProfiles.sOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{supportsFlowType && (
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={flowTypeFilter}
|
||||
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="all">{t('kProfiles.allFlow')}</option>
|
||||
<option value="hf">{t('kProfiles.hfOnly')}</option>
|
||||
<option value="s">{t('kProfiles.sOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={sortOption}
|
||||
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
|
||||
profile={editingProfile}
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
initialNote={note}
|
||||
initialNoteKey={key}
|
||||
onSaveNote={handleSaveNote}
|
||||
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
|
||||
<KProfileModal
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
onClose={() => {
|
||||
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
|
||||
<KProfileModal
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
// Pass profile data but without slot_id to create a new profile
|
||||
|
||||
@@ -74,6 +74,17 @@ export function Layout() {
|
||||
const { t } = useTranslation();
|
||||
const isSidebarCompact = useIsSidebarCompact();
|
||||
|
||||
// Bug-report panel state lives here because the trigger moves (#2750,
|
||||
// reporter @goodjaltman). Below the sidebar-compact breakpoint the floating
|
||||
// disc is replaced by a button in the compact header: the bottom-right corner
|
||||
// is the most contended region in the app — the Profiles scroll-to-top FAB,
|
||||
// the floating camera window and its resize handle, the Group Edit save bar,
|
||||
// the bulk-selection toolbars, and the File Manager / Archives per-card
|
||||
// action buttons all live there — and a fixed 48px disc sits on top of
|
||||
// whichever of them happens to be underneath. Moving out of the corner is
|
||||
// the only fix that covers in-flow content as well as fixed overlays.
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false);
|
||||
|
||||
// Theme toggle: mode → icon and tooltip
|
||||
const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
|
||||
const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
|
||||
@@ -496,6 +507,15 @@ export function Layout() {
|
||||
alt="Bambuddy"
|
||||
className="h-8 ml-3"
|
||||
/>
|
||||
{/* Bug report — the compact-layout home of the floating bubble. */}
|
||||
<button
|
||||
onClick={() => setBugReportOpen(true)}
|
||||
className="ml-auto p-2 -mr-2 rounded-lg text-red-500 hover:bg-bambu-dark-tertiary transition-colors"
|
||||
title={t('bugReport.title')}
|
||||
aria-label={t('bugReport.title')}
|
||||
>
|
||||
<Bug className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -1110,7 +1130,16 @@ export function Layout() {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<BugReportBubble />
|
||||
{/* The panel always mounts here, at the Layout root. It must not move
|
||||
into the header alongside its compact-layout trigger: the header is
|
||||
`fixed z-40` and so its own stacking context, which would cap the
|
||||
z-50 panel at the header's level and bury it under every ordinary
|
||||
modal in the app. */}
|
||||
<BugReportBubble
|
||||
showTrigger={!isSidebarCompact}
|
||||
open={bugReportOpen}
|
||||
onOpenChange={setBugReportOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
|
||||
import { ModelViewer } from './ModelViewer';
|
||||
import { GcodeViewer } from './GcodeViewer';
|
||||
import { Button } from './Button';
|
||||
import { api, withStreamToken } from '../api/client';
|
||||
import { openInSlicer, type SlicerType } from '../utils/slicer';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
|
||||
import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
|
||||
|
||||
type ViewTab = '3d' | 'gcode';
|
||||
@@ -32,14 +33,105 @@ interface Capabilities {
|
||||
filament_colors: string[];
|
||||
}
|
||||
|
||||
interface SlicerSplitButtonProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
dropdownLabel: string;
|
||||
onPrimary: () => void;
|
||||
items: Array<{ key: string; label: string; onClick: () => void }>;
|
||||
}
|
||||
|
||||
// Split button: the primary part runs the default slicer action, the chevron
|
||||
// opens a dropdown with the other slicer options. Outside click or Escape
|
||||
// (non-propagating) closes the dropdown. The split only renders when the
|
||||
// action is already possible, so there is no disabled state to express.
|
||||
function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex" ref={containerRef}>
|
||||
<div className="flex relative z-50">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onPrimary();
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-label={dropdownLabel}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="rounded-l-none border-l border-bambu-dark px-2"
|
||||
>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onClick();
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
|
||||
// Desktop "Open in Slicer" target — falls back to preferred_slicer when the
|
||||
// user hasn't explicitly chosen a different desktop slicer (#1329). This
|
||||
// variable is only used for URI-handoff; sidecar slicing keeps using
|
||||
// preferred_slicer directly.
|
||||
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
|
||||
const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
|
||||
const isLibrary = libraryFileId != null;
|
||||
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
|
||||
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
||||
@@ -280,7 +372,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
||||
};
|
||||
}, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
|
||||
|
||||
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
|
||||
// Which file types can be handed to a desktop slicer via the URL protocol
|
||||
// handler — and sliced in-app via the sidecar. Shares its list with
|
||||
// `isSliceableFilename()`, which the File Manager's card menu and list row
|
||||
// use, so a file's "Slice" action and its 3D-preview slicer button can no
|
||||
// longer disagree about the same file.
|
||||
const slicerReadyType = isSliceableFileType(fileType);
|
||||
const canOpenInSlicer = isLibrary ? slicerReadyType : true;
|
||||
|
||||
// When the user has the in-app Slicer API enabled (Settings → Workflow →
|
||||
// Slicer → Use Slicer API), library-mode previews route the header's slicer
|
||||
@@ -288,39 +386,49 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
||||
// in the file-row actions. Falls back to the external-slicer launcher when
|
||||
// the API is off, when no in-app handler is wired (e.g. archive preview),
|
||||
// or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
|
||||
const sliceableType = (() => {
|
||||
const t = (fileType || '').toLowerCase();
|
||||
return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
|
||||
})();
|
||||
const useBambuddySlicer = Boolean(
|
||||
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
|
||||
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
|
||||
);
|
||||
|
||||
const handleOpenInSlicer = async () => {
|
||||
const handleOpenInSlicer = async (slicer: SlicerType) => {
|
||||
if (!canOpenInSlicer) return;
|
||||
const filename = title || 'model';
|
||||
try {
|
||||
if (isLibrary) {
|
||||
const { token } = await api.createLibrarySlicerToken(libraryFileId!);
|
||||
const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
openInSlicer(`${window.location.origin}${path}`, slicer);
|
||||
} else {
|
||||
const { token } = await api.createArchiveSlicerToken(archiveId!);
|
||||
const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
openInSlicer(`${window.location.origin}${path}`, slicer);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to direct URL (works when auth is disabled)
|
||||
// Fallback to direct URL (works when auth is disabled). With auth on the
|
||||
// slicer may then hit a 401, so surface the failure instead of making a
|
||||
// permission denial look identical to "no slicer installed".
|
||||
showToast(t('modelViewer.openInSlicerFailed'), 'error');
|
||||
if (isLibrary) {
|
||||
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicer(downloadUrl, slicer);
|
||||
} else {
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicer(downloadUrl, slicer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
|
||||
? ['bambu_studio', 'orcaslicer']
|
||||
: [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
|
||||
const slicerName = (slicer: SlicerType) =>
|
||||
slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
|
||||
const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
|
||||
key: slicer,
|
||||
label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
|
||||
onClick: () => handleOpenInSlicer(slicer),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
|
||||
@@ -344,12 +452,23 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{useBambuddySlicer ? (
|
||||
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
|
||||
<Cog className="w-4 h-4" />
|
||||
{t('slice.action')}
|
||||
</Button>
|
||||
<SlicerSplitButton
|
||||
icon={<Cog className="w-4 h-4" />}
|
||||
label={t('slice.action')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => onSliceWithBambuddy?.()}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : canOpenInSlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<ExternalLink className="w-4 h-4" />}
|
||||
label={t('modelViewer.openInSlicer')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => handleOpenInSlicer(preferredSlicer)}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('modelViewer.openInSlicer')}
|
||||
</Button>
|
||||
|
||||
@@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
{provider.on_ai_failure_detection && (
|
||||
<span className="px-2 py-0.5 bg-fuchsia-100 dark:bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
|
||||
)}
|
||||
{provider.on_ha_sensor_alert && (
|
||||
<span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.haSensorAlert')}</span>
|
||||
)}
|
||||
{provider.on_filament_low && (
|
||||
<span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
|
||||
)}
|
||||
@@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">{t('notifications.haSensorAlert')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('notifications.haSensorAlertDescription')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_ha_sensor_alert ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_ha_sensor_alert: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
|
||||
<Toggle
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff, Lock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
|
||||
@@ -386,6 +386,11 @@ export function OIDCProviderSettings() {
|
||||
<X className="w-3 h-3" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
{provider.is_env_managed && (
|
||||
<span className="flex items-center gap-1 text-xs text-bambu-green">
|
||||
<Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
@@ -393,45 +398,59 @@ export function OIDCProviderSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{provider.icon_url && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshIconMutation.mutate(provider.id)}
|
||||
disabled={refreshIconMutation.isPending}
|
||||
title={t('settings.oidc.refreshIcon')}
|
||||
data-testid={`refresh-icon-${provider.id}`}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
{/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
|
||||
and the API answers 409, so offering any of these would promise
|
||||
a change that cannot land -- the icon routes included, where the
|
||||
click only ever produced an error toast. */}
|
||||
{!provider.is_env_managed && (
|
||||
<>
|
||||
{provider.icon_url && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshIconMutation.mutate(provider.id)}
|
||||
disabled={refreshIconMutation.isPending}
|
||||
title={t('settings.oidc.refreshIcon')}
|
||||
data-testid={`refresh-icon-${provider.id}`}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
{provider.has_icon && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => removeIconMutation.mutate(provider.id)}
|
||||
disabled={removeIconMutation.isPending}
|
||||
title={t('settings.oidc.removeIcon')}
|
||||
data-testid={`remove-icon-${provider.id}`}
|
||||
>
|
||||
<ImageOff className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
checked={provider.is_enabled}
|
||||
onChange={() => toggleEnabled(provider)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
|
||||
data-testid={`edit-provider-${provider.id}`}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
data-testid={`delete-provider-${provider.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{provider.has_icon && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => removeIconMutation.mutate(provider.id)}
|
||||
disabled={removeIconMutation.isPending}
|
||||
title={t('settings.oidc.removeIcon')}
|
||||
data-testid={`remove-icon-${provider.id}`}
|
||||
>
|
||||
<ImageOff className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
checked={provider.is_enabled}
|
||||
onChange={() => toggleEnabled(provider)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parsePreheatFilamentTargets,
|
||||
serializePreheatFilamentTargets,
|
||||
} from '../utils/preheatFilamentTargets';
|
||||
import { MAX_CHAMBER_TEMP_C } from '../utils/printer';
|
||||
|
||||
interface Props {
|
||||
// JSON-encoded map; empty string means "use bundled defaults".
|
||||
@@ -15,7 +16,7 @@ interface Props {
|
||||
|
||||
// Per-filament chamber target editor for Settings → Workflow → Preheat card
|
||||
// (#1468). Renders one row per filament type with a numeric input clamped to
|
||||
// 0-60 °C. Stripping back to the bundled defaults is handled by the parent
|
||||
// 0-MAX_CHAMBER_TEMP_C. Stripping back to the bundled defaults is handled by the parent
|
||||
// (Reset button next to the section title) — passing an empty string upward
|
||||
// is the canonical "use defaults" signal, which keeps the editor stateless
|
||||
// across resets.
|
||||
@@ -24,7 +25,7 @@ export function PreheatFilamentTargetsEditor({ value, onChange, disabled = false
|
||||
const map = parsePreheatFilamentTargets(value);
|
||||
|
||||
const updateOne = (key: string, next: number) => {
|
||||
const clamped = Math.max(0, Math.min(60, Math.round(next)));
|
||||
const clamped = Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, Math.round(next)));
|
||||
const updated = { ...map, [key]: clamped };
|
||||
onChange(serializePreheatFilamentTargets(updated));
|
||||
};
|
||||
@@ -45,7 +46,7 @@ export function PreheatFilamentTargetsEditor({ value, onChange, disabled = false
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
max={MAX_CHAMBER_TEMP_C}
|
||||
step={1}
|
||||
value={current}
|
||||
onChange={(e) => updateOne(key, parseInt(e.target.value, 10) || 0)}
|
||||
|
||||
@@ -27,11 +27,62 @@ export function FilamentMapping({
|
||||
forceColorMatch,
|
||||
onForceColorMatchChange,
|
||||
plateLabel,
|
||||
archiveAmsMapping,
|
||||
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
// "Mapping" toggle (only shown when the archive has a saved slicer pick):
|
||||
// ON selects every slot straight from `archiveAmsMapping`, bypassing the
|
||||
// type/color auto-match entirely — same mechanism as a manual per-slot
|
||||
// pick (`manualMappings`), just applied to every required slot at once.
|
||||
// OFF removes exactly those overrides so the panel falls back to its
|
||||
// normal auto-match, without touching any *other* manual picks the user
|
||||
// made by hand.
|
||||
const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
|
||||
// Which slot IDs the ON branch below actually wrote into manualMappings —
|
||||
// so OFF can undo exactly those and leave any *other* manual pick the user
|
||||
// made by hand (before or after pressing the button) untouched.
|
||||
const appliedSlotIdsRef = useRef<number[]>([]);
|
||||
|
||||
// Reset the toggle whenever the saved mapping it would apply changes — a
|
||||
// different printer, plate selection, or archive entirely. Without this
|
||||
// the button can read ON (green) from a previous printer/archive/plate
|
||||
// even though it was never pressed against the mapping currently in scope.
|
||||
useEffect(() => {
|
||||
setUsingArchiveMapping(false);
|
||||
appliedSlotIdsRef.current = [];
|
||||
}, [archiveAmsMapping, plateLabel, printerId]);
|
||||
|
||||
const toggleArchiveMapping = () => {
|
||||
if (!archiveAmsMapping || !filamentReqs?.filaments) return;
|
||||
if (usingArchiveMapping) {
|
||||
const next = { ...manualMappings };
|
||||
for (const slotId of appliedSlotIdsRef.current) {
|
||||
delete next[slotId];
|
||||
}
|
||||
onManualMappingChange(next);
|
||||
appliedSlotIdsRef.current = [];
|
||||
setUsingArchiveMapping(false);
|
||||
return;
|
||||
}
|
||||
const next = { ...manualMappings };
|
||||
const appliedSlotIds: number[] = [];
|
||||
for (const req of filamentReqs.filaments) {
|
||||
const idx = req.slot_id - 1;
|
||||
// A negative value (e.g. the external spool sentinel) means the
|
||||
// slicer didn't resolve this filament to an AMS tray — leave that
|
||||
// slot's existing auto-match/manual pick alone rather than clearing it.
|
||||
if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
|
||||
next[req.slot_id] = archiveAmsMapping[idx];
|
||||
appliedSlotIds.push(req.slot_id);
|
||||
}
|
||||
}
|
||||
onManualMappingChange(next);
|
||||
appliedSlotIdsRef.current = appliedSlotIds;
|
||||
setUsingArchiveMapping(true);
|
||||
};
|
||||
|
||||
// Fetch printer status
|
||||
const { data: printerStatus } = useQuery({
|
||||
@@ -228,16 +279,33 @@ export function FilamentMapping({
|
||||
{isExpanded && (
|
||||
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Re-read</span>
|
||||
</button>
|
||||
<span className="text-xs text-bambu-gray">{t('printModal.clickToChangeSlot')}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{archiveAmsMapping && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleArchiveMapping}
|
||||
title={t('printModal.useArchiveMappingTooltip')}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded border transition-colors ${
|
||||
usingArchiveMapping
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{t('printModal.useArchiveMapping')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span>{t('printModal.reRead')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{filamentComparison.map((item, idx) => {
|
||||
// #1717: surface the same per-slot force-color-match checkbox here
|
||||
|
||||
@@ -19,6 +19,8 @@ export function PlateSelector({
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
multiSelect,
|
||||
quantities,
|
||||
onQuantityChange,
|
||||
}: PlateSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -28,6 +30,10 @@ export function PlateSelector({
|
||||
}
|
||||
|
||||
const allSelected = selectedPlates.size === plates.length;
|
||||
const showQuantities = !!quantities && !!onQuantityChange;
|
||||
const totalRuns = showQuantities
|
||||
? plates.reduce((sum, p) => (selectedPlates.has(p.index) ? sum + (quantities[p.index] ?? 1) : sum), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
@@ -60,15 +66,22 @@ export function PlateSelector({
|
||||
{plates.map((plate) => {
|
||||
const isSelected = selectedPlates.has(plate.index);
|
||||
return (
|
||||
<button
|
||||
/* The quantity stepper can't live inside the selection button —
|
||||
nesting an input in a button is invalid and every keystroke
|
||||
would toggle the plate. The card is a div; the selectable
|
||||
region stays a button beside the stepper. */
|
||||
<div
|
||||
key={plate.index}
|
||||
type="button"
|
||||
onClick={() => onToggle(plate.index)}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
|
||||
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
|
||||
isSelected
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(plate.index)}
|
||||
className="flex items-center gap-2 text-left min-w-0 flex-1"
|
||||
>
|
||||
{multiSelect && (
|
||||
isSelected
|
||||
@@ -114,9 +127,33 @@ export function PlateSelector({
|
||||
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{showQuantities && isSelected && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={quantities[plate.index] ?? 1}
|
||||
aria-label={t('queue.plateQuantityLabel', {
|
||||
plate: plate.name || t('queue.plateNumber', { index: plate.index }),
|
||||
})}
|
||||
onChange={(e) =>
|
||||
onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))
|
||||
}
|
||||
className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showQuantities && totalRuns > selectedPlates.size && (
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{t('queue.plateQuantityTotal', { count: totalRuns })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CALIBRATION_MODE_ACTIVE,
|
||||
CALIBRATION_MODE_INACTIVE,
|
||||
} from '../../utils/calibrationMode';
|
||||
import { MAX_CHAMBER_TEMP_C } from '../../utils/printer';
|
||||
|
||||
type OptionConfig = {
|
||||
key: keyof PrintOptionsType;
|
||||
@@ -79,7 +80,7 @@ export function PrintOptionsPanel({
|
||||
if (Number.isNaN(parsed)) return;
|
||||
onChange({
|
||||
...options,
|
||||
preheat_chamber_target_override: Math.max(0, Math.min(60, parsed)),
|
||||
preheat_chamber_target_override: Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, parsed)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -188,7 +189,7 @@ export function PrintOptionsPanel({
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
max={MAX_CHAMBER_TEMP_C}
|
||||
step={1}
|
||||
value={options.preheat_chamber_target_override ?? ''}
|
||||
onChange={(e) => handlePreheatTarget(e.target.value)}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
/** One sliced file offered as an alternative for a cross-model job (#671). */
|
||||
export interface VariantCandidate {
|
||||
id: number;
|
||||
filename: string;
|
||||
sliced_for_model: string | null;
|
||||
}
|
||||
|
||||
interface VariantCandidatesProps {
|
||||
candidates: VariantCandidate[];
|
||||
onReorder: (next: VariantCandidate[]) => void;
|
||||
/** file id -> chosen plate, for the multi-plate candidates only. */
|
||||
plateByFile: Record<number, number | null>;
|
||||
onPlateChange: (fileId: number, plateId: number | null) => void;
|
||||
/** Show the set without offering to change it — the edit-queue-item case,
|
||||
* where reordering would need a variant-level API that doesn't exist. */
|
||||
readOnly?: boolean;
|
||||
/** Replaces the help line under the heading when read-only. */
|
||||
readOnlyNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered candidate list for a cross-model print (#671).
|
||||
*
|
||||
* Only two things are configured per candidate: its order, and — when the file
|
||||
* holds more than one plate — which plate to run. Everything else on the modal
|
||||
* (filament overrides, print options, schedule) stays shared, because that is
|
||||
* already how model-based assignment works: the printer is unknown at queue
|
||||
* time, so there is nothing per-machine to configure. The AMS mapping in
|
||||
* particular is deliberately absent — the scheduler computes it against the
|
||||
* printer it actually picks, exactly as it does for a single-model job.
|
||||
*
|
||||
* Order is the answer to "which would you rather have when both are free", so
|
||||
* it is explicit rather than left to whichever printer the matcher saw first.
|
||||
* Move buttons instead of drag: the list is two or three rows, and buttons work
|
||||
* from the keyboard without a drag-and-drop dependency.
|
||||
*/
|
||||
export function VariantCandidates({
|
||||
candidates,
|
||||
onReorder,
|
||||
plateByFile,
|
||||
onPlateChange,
|
||||
readOnly = false,
|
||||
readOnlyNote,
|
||||
}: VariantCandidatesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const plateQueries = useQueries({
|
||||
// Read-only mode shows a job that is already queued — its plates were
|
||||
// chosen when it was created, so there is nothing to fetch or offer.
|
||||
queries: readOnly
|
||||
? []
|
||||
: candidates.map((c) => ({
|
||||
queryKey: ['library-file-plates', c.id],
|
||||
queryFn: () => api.getLibraryFilePlates(c.id),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const platesByFile = useMemo(() => {
|
||||
const out: Record<number, { index: number; name: string | null }[]> = {};
|
||||
candidates.forEach((c, i) => {
|
||||
const data = plateQueries[i]?.data;
|
||||
if (data?.is_multi_plate && data.plates.length > 1) {
|
||||
out[c.id] = data.plates.map((p) => ({ index: p.index, name: p.name }));
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [candidates, plateQueries]);
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= candidates.length) return;
|
||||
const next = [...candidates];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
onReorder(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PrinterIcon className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mb-2">
|
||||
{readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{candidates.map((candidate, index) => {
|
||||
const plates = platesByFile[candidate.id];
|
||||
// Shown exactly as the 3MF declares it. The backend normalizes when it
|
||||
// resolves the candidate; echoing its own words here avoids a second,
|
||||
// possibly disagreeing, normalizer in the browser.
|
||||
const model = candidate.sliced_for_model;
|
||||
return (
|
||||
<div
|
||||
key={candidate.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-bambu-dark-tertiary p-2"
|
||||
>
|
||||
<span className="text-xs font-mono text-bambu-gray w-5 shrink-0">{index + 1}.</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-bambu-green/10 text-bambu-green text-xs shrink-0">
|
||||
{model || t('printModal.variants.unknownModel')}
|
||||
</span>
|
||||
<span className="text-sm text-white truncate min-w-0 flex-1" title={candidate.filename}>
|
||||
{candidate.filename}
|
||||
</span>
|
||||
|
||||
{plates && (
|
||||
<label className="flex items-center gap-1 text-xs text-bambu-gray shrink-0">
|
||||
<Layers className="w-3 h-3" />
|
||||
<select
|
||||
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-1 py-0.5 text-xs text-white"
|
||||
value={plateByFile[candidate.id] ?? plates[0].index}
|
||||
onChange={(e) => onPlateChange(candidate.id, Number(e.target.value))}
|
||||
aria-label={t('printModal.variants.plateFor', { filename: candidate.filename })}
|
||||
>
|
||||
{plates.map((p) => (
|
||||
<option key={p.index} value={p.index}>
|
||||
{p.name || t('printModal.plateN', { n: p.index })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
|
||||
aria-label={t('printModal.variants.moveUp')}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === candidates.length - 1}
|
||||
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
|
||||
aria-label={t('printModal.variants.moveDown')}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Reading the archive's saved slicer AMS-slot pick back out of `extra_data`.
|
||||
*
|
||||
* A virtual printer with "Save AMS mapping" on stores the slicer's own
|
||||
* live-resolved tray choice on the archive as
|
||||
* `extra_data.slicer_ams_mapping = { mapping, printer_id }` (written by
|
||||
* `ArchiveService.archive_print`). The tray IDs in `mapping` are global tray
|
||||
* IDs, which only mean something against the AMS layout of the one printer
|
||||
* they were resolved against — slot 3 on another printer can hold a completely
|
||||
* different spool. `printer_id` records which printer that was.
|
||||
*
|
||||
* Lives here rather than inline in the modal so the printer-scoping rule can
|
||||
* be tested on its own: it is the only thing standing between a saved mapping
|
||||
* and the wrong physical spool.
|
||||
*/
|
||||
|
||||
/** Shape of `extra_data.slicer_ams_mapping`. Every field optional — this is
|
||||
* free-form JSON off the wire, and older archives predate the key entirely. */
|
||||
export interface SavedSlicerAmsMapping {
|
||||
mapping?: number[];
|
||||
printer_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved mapping, but only when it is safe to apply to `printerId`.
|
||||
*
|
||||
* Returns `undefined` — meaning "no saved mapping in scope, behave as before" —
|
||||
* when the archive has none, when the stored value is malformed, when no
|
||||
* printer is selected yet, or when the selected printer is not the one the
|
||||
* mapping was resolved against.
|
||||
*/
|
||||
export function resolveArchiveSlicerAmsMapping(
|
||||
extraData: Record<string, unknown> | null | undefined,
|
||||
printerId: number | null | undefined,
|
||||
): number[] | undefined {
|
||||
// No printer selected means there is nothing to compare against. Bailing
|
||||
// here also stops `undefined === undefined` from reading as a match below.
|
||||
if (printerId == null) return undefined;
|
||||
|
||||
const saved = extraData?.slicer_ams_mapping as SavedSlicerAmsMapping | undefined;
|
||||
if (!saved || typeof saved !== 'object') return undefined;
|
||||
if (saved.printer_id !== printerId) return undefined;
|
||||
if (!Array.isArray(saved.mapping) || saved.mapping.length === 0) return undefined;
|
||||
|
||||
return saved.mapping;
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import { getCurrencySymbol } from '../../utils/currency';
|
||||
import { getBedTypeInfo } from '../../utils/bedType';
|
||||
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
|
||||
import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
|
||||
import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
|
||||
import { FilamentMapping } from './FilamentMapping';
|
||||
import { FilamentOverride } from './FilamentOverride';
|
||||
import { PlateSelector } from './PlateSelector';
|
||||
import { PrinterSelector } from './PrinterSelector';
|
||||
import { PrintOptionsPanel } from './PrintOptions';
|
||||
import { ScheduleOptionsPanel } from './ScheduleOptions';
|
||||
import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
|
||||
import { CostCenterSelect } from './CostCenterSelect';
|
||||
import type {
|
||||
AssignmentMode,
|
||||
@@ -58,6 +60,7 @@ export function PrintModal({
|
||||
onSuccess,
|
||||
projectId,
|
||||
cleanupLibraryAfterDispatch,
|
||||
variantFiles,
|
||||
}: PrintModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -68,6 +71,27 @@ export function PrintModal({
|
||||
const isLibraryFile = !!libraryFileId && !archiveId;
|
||||
const isEditing = mode === 'edit-queue-item';
|
||||
|
||||
// Cross-model alternatives (#671). One candidate is not a choice, so a
|
||||
// single-entry list behaves exactly like an ordinary print.
|
||||
const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
|
||||
// Editing an already-queued cross-model item. The candidates are shown so the
|
||||
// dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
|
||||
// did before, offering a printer picker whose Save would have left a row with
|
||||
// both variants and a printer_id. They are not editable here: changing the
|
||||
// set after queueing needs a variant-level API that doesn't exist, and the
|
||||
// backend refuses the printer/model change either way.
|
||||
const editingVariants: VariantCandidate[] =
|
||||
mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
|
||||
? queueItem!.variants!.map((v) => ({
|
||||
id: v.library_file_id,
|
||||
filename: v.filename,
|
||||
sliced_for_model: v.target_model,
|
||||
}))
|
||||
: [];
|
||||
const hasEditingVariants = editingVariants.length > 0;
|
||||
const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
|
||||
const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
|
||||
|
||||
type FilamentWarningItem = {
|
||||
printerName: string;
|
||||
slotLabel: string;
|
||||
@@ -101,6 +125,11 @@ export function PrintModal({
|
||||
// Quantity — number of copies (creates a batch if > 1)
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
// Per-plate quantities for multi-plate files (#342). Keyed by plate index;
|
||||
// a plate with no entry means one run. Only used in create mode on a
|
||||
// multi-plate file, where it replaces the single global Quantity field.
|
||||
const [plateQuantities, setPlateQuantities] = useState<Record<number, number>>({});
|
||||
|
||||
const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem) {
|
||||
return {
|
||||
@@ -165,6 +194,11 @@ export function PrintModal({
|
||||
|
||||
// Assignment mode: 'printer' (specific) or 'model' (any of model)
|
||||
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
|
||||
// Cross-model alternatives are model-based by definition — naming one
|
||||
// printer would defeat the point of offering the other file.
|
||||
if (isCrossModel) {
|
||||
return 'model';
|
||||
}
|
||||
// Initialize from queue item if editing with target_model
|
||||
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
||||
return 'model';
|
||||
@@ -357,6 +391,19 @@ export function PrintModal({
|
||||
// Get sliced_for_model from archive or library file
|
||||
const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
|
||||
|
||||
// The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
|
||||
// mapping" virtual-printer setting) — undefined for library files or
|
||||
// archives that predate the feature / had it off at print time, and
|
||||
// deliberately undefined unless the selected printer is the one the mapping
|
||||
// was resolved against. See `resolveArchiveSlicerAmsMapping`.
|
||||
const archiveSlicerAmsMapping = useMemo(
|
||||
() =>
|
||||
isLibraryFile
|
||||
? undefined
|
||||
: resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
|
||||
[isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
|
||||
);
|
||||
|
||||
// Fetch plates for archives
|
||||
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
|
||||
queryKey: ['archive-plates', archiveId],
|
||||
@@ -403,6 +450,42 @@ export function PrintModal({
|
||||
enabled: assignmentMode === 'model' && !!targetModel,
|
||||
});
|
||||
|
||||
// A cross-model job (#671) has no single target model, so the query above is
|
||||
// disabled and the override UI would silently vanish — leaving less control
|
||||
// than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
|
||||
// and offer the union: the job can land on any of them, so anything loaded on
|
||||
// any of them is a legitimate choice. Picking one only some models have is
|
||||
// allowed and meaningful — it narrows which candidates can match.
|
||||
const candidateModels = useMemo(
|
||||
() => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
|
||||
[candidates],
|
||||
);
|
||||
const candidateFilamentQueries = useQueries({
|
||||
queries: isCrossModel
|
||||
? candidateModels.map((model) => ({
|
||||
queryKey: ['available-filaments', model, targetLocation],
|
||||
queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
const crossModelFilaments = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: NonNullable<typeof availableFilaments> = [];
|
||||
for (const query of candidateFilamentQueries) {
|
||||
for (const filament of query.data ?? []) {
|
||||
// Same type+colour loaded on two models is one choice, not two.
|
||||
const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(filament);
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [candidateFilamentQueries]);
|
||||
|
||||
const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
|
||||
|
||||
// Only fetch printer status when single printer selected (for filament mapping)
|
||||
const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
|
||||
queryKey: ['printer-status', effectivePrinterId],
|
||||
@@ -821,17 +904,21 @@ export function PrintModal({
|
||||
showToast('Please select at least one printer', 'error');
|
||||
return;
|
||||
}
|
||||
if (assignmentMode === 'model' && !targetModel) {
|
||||
// A cross-model job has no single target model — each candidate carries its
|
||||
// own, and the backend gates each of them separately. Both checks below are
|
||||
// about the one-model case only.
|
||||
if (!isCrossModel && assignmentMode === 'model' && !targetModel) {
|
||||
showToast('Please select a target printer model', 'error');
|
||||
return;
|
||||
}
|
||||
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
|
||||
// gets inline feedback instead of a failed request.
|
||||
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
|
||||
if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
|
||||
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsSubmitting(true);
|
||||
// Calculate total API calls: plates × printers (or 1 for model-based)
|
||||
const platesToQueue = selectedPlates.size > 1
|
||||
@@ -892,28 +979,87 @@ export function PrintModal({
|
||||
? buildFilamentOverridesArray(perPlateReqs.get(plateId))
|
||||
: filamentOverridesArray;
|
||||
|
||||
// Multi-plate auto-batch: when the user adds 2+ plates from one source in
|
||||
// a single create submission, pre-create a PrintBatch and pass its
|
||||
// id to each subsequent addToQueue call so the queue UI groups them as a
|
||||
// collapsible batch. Only triggered for single-target submissions —
|
||||
// multi-printer fan-out keeps the old per-item shape.
|
||||
// Cross-model alternatives (#671): ONE item carrying a candidate per file,
|
||||
// in the order the user arranged. This returns before the plate/printer
|
||||
// fan-out below because it deliberately fans out to nothing — the whole
|
||||
// point is that exactly one of these candidates ever runs.
|
||||
//
|
||||
// Filament overrides are shared rather than per-candidate, matching how
|
||||
// single-model assignment already behaves: the printer is unknown at queue
|
||||
// time, so what is expressed here is "this job needs PETG", which is true of
|
||||
// every slice of the same job. The AMS mapping is likewise absent — the
|
||||
// scheduler computes it against the printer it actually picks.
|
||||
if (isCrossModel) {
|
||||
try {
|
||||
await api.addToQueue({
|
||||
variants: candidates.map((c) => ({
|
||||
library_file_id: c.id,
|
||||
plate_id: candidatePlates[c.id] ?? null,
|
||||
filament_overrides: filamentOverridesArray,
|
||||
})),
|
||||
target_location: targetLocation,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
gcode_injection: scheduleOptions.gcodeInjection,
|
||||
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
: undefined,
|
||||
quantity,
|
||||
...printOptions,
|
||||
project_id: projectId ?? undefined,
|
||||
});
|
||||
showToast(t('printModal.variants.queued', { count: candidates.length }), 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), 'error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch order (#342): a create submission that produces more than one run
|
||||
// from one source is pre-created as a batch carrying per-plate targets,
|
||||
// and its id is passed to each subsequent addToQueue call. The targets are
|
||||
// what make the order able to say a failed run is still owed — without
|
||||
// them the batch only knows what it happened to queue. Only for
|
||||
// single-target submissions; multi-printer fan-out keeps the old per-item
|
||||
// shape, where "how many" is answered by the printer count.
|
||||
const plateTargets = platesToQueue.map((plate, index) => {
|
||||
const plateIndex = plate ? plate.index : selectedPlate;
|
||||
return {
|
||||
plate_id: plateIndex,
|
||||
plate_name: plate ? (plate.name || null) : null,
|
||||
quantity_target: quantityForPlate(plateIndex),
|
||||
sort_order: index,
|
||||
};
|
||||
});
|
||||
const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0);
|
||||
const shouldAutoBatch =
|
||||
mode === 'create'
|
||||
&& platesToQueue.length > 1
|
||||
&& (platesToQueue.length > 1 || totalRuns > 1)
|
||||
&& (assignmentMode === 'model' || selectedPrinters.length === 1);
|
||||
let autoBatchId: number | null = null;
|
||||
if (shouldAutoBatch) {
|
||||
try {
|
||||
const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
|
||||
const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
|
||||
const batchName = platesToQueue.length > 1
|
||||
? `${baseName || 'Batch'} · ${platesToQueue.length} plates`
|
||||
: `${baseName || 'Batch'} ×${totalRuns}`;
|
||||
const batch = await api.createBatch({
|
||||
name: batchName,
|
||||
archive_id: isLibraryFile ? undefined : archiveId,
|
||||
library_file_id: isLibraryFile ? libraryFileId : undefined,
|
||||
plates: plateTargets,
|
||||
});
|
||||
autoBatchId = batch.id;
|
||||
} catch {
|
||||
// Non-fatal: fall back to ungrouped items so the queue still works.
|
||||
// The server still creates a plain batch when quantity > 1, so the
|
||||
// queue grouping survives even when the order layer doesn't.
|
||||
autoBatchId = null;
|
||||
}
|
||||
}
|
||||
@@ -1001,8 +1147,9 @@ export function PrintModal({
|
||||
} else {
|
||||
// Add-to-queue mode with model-based assignment
|
||||
const queueData = getQueueData(null, plateId);
|
||||
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
|
||||
applyAsapInsertion(queueData, null, effectiveQuantity);
|
||||
const plateQuantity = quantityForPlate(plateId);
|
||||
if (plateQuantity > 1) queueData.quantity = plateQuantity;
|
||||
applyAsapInsertion(queueData, null, plateQuantity);
|
||||
await addToQueueMutation.mutateAsync(queueData);
|
||||
}
|
||||
results.success++;
|
||||
@@ -1062,8 +1209,9 @@ export function PrintModal({
|
||||
} else {
|
||||
// New print mode, staggered print, or edit mode with additional entries
|
||||
const queueData = getQueueData(printerId, plateId);
|
||||
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
|
||||
applyAsapInsertion(queueData, printerId, effectiveQuantity);
|
||||
const plateQuantity = quantityForPlate(plateId);
|
||||
if (plateQuantity > 1) queueData.quantity = plateQuantity;
|
||||
applyAsapInsertion(queueData, printerId, plateQuantity);
|
||||
// Apply stagger offset for groups after the first
|
||||
if (useStagger) {
|
||||
const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
|
||||
@@ -1131,9 +1279,11 @@ export function PrintModal({
|
||||
|
||||
// Need valid printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
|
||||
if (assignmentMode === 'model' && !targetModel) return false;
|
||||
// Both are about the single-model case. A cross-model job has no one target
|
||||
// model, and each candidate is gated against its own by the backend (#671).
|
||||
if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
|
||||
// Cross-model mismatch cannot be queued (#2578)
|
||||
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
|
||||
if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
|
||||
|
||||
// For multi-plate files, need at least one plate selected
|
||||
if (isMultiPlate && selectedPlates.size === 0) return false;
|
||||
@@ -1162,11 +1312,27 @@ export function PrintModal({
|
||||
perPlateReqsPending,
|
||||
perPlateReqsFailed,
|
||||
printerStatusLoading,
|
||||
isCrossModel,
|
||||
]);
|
||||
|
||||
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
|
||||
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
|
||||
|
||||
// On a multi-plate file the per-plate steppers own the quantity and the
|
||||
// global field is hidden (#342) — the reporter's case is "plate 1 once,
|
||||
// plate 2 twice", which one shared number cannot express. Single-plate
|
||||
// files, and edit mode, keep the single field exactly as before.
|
||||
const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1;
|
||||
|
||||
/** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
|
||||
const quantityForPlate = (plateIndex: number | null): number => {
|
||||
if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity;
|
||||
// Multi-printer fan-out already means one copy per printer; multiplying by
|
||||
// a per-plate count on top would silently produce plates × printers × n.
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1;
|
||||
return Math.max(1, plateQuantities[plateIndex] ?? 1);
|
||||
};
|
||||
|
||||
// Clear gcode_injection if the admin removes all snippets while the modal
|
||||
// is open — the checkbox itself hides via hasGcodeSnippets in
|
||||
// ScheduleOptions, but the boolean would otherwise stay true and ship to
|
||||
@@ -1235,8 +1401,13 @@ export function PrintModal({
|
||||
// is the filament each slot must be printed in, which the scheduler matches
|
||||
// against whatever printer of the model it picks. Needs the model's loaded
|
||||
// filaments to offer as alternatives.
|
||||
// Cross-model items have no targetModel by design — their candidates each
|
||||
// carry their own — so gate on having somewhere to source choices from.
|
||||
const showFilamentOverride =
|
||||
assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
|
||||
assignmentMode === 'model'
|
||||
&& (isCrossModel || !!targetModel)
|
||||
&& !!effectiveAvailableFilaments
|
||||
&& effectiveAvailableFilaments.length > 0;
|
||||
|
||||
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
|
||||
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
|
||||
@@ -1329,10 +1500,39 @@ export function PrintModal({
|
||||
onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
|
||||
onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
|
||||
multiSelect={!isEditing}
|
||||
quantities={usePerPlateQuantities ? plateQuantities : undefined}
|
||||
onQuantityChange={usePerPlateQuantities
|
||||
? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value }))
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
{/* Cross-model alternatives (#671) replace the printer picker entirely:
|
||||
the user already answered "which printer" by choosing these files,
|
||||
and the remaining question is only which they'd rather have. */}
|
||||
{isCrossModel && (
|
||||
<VariantCandidates
|
||||
candidates={candidates}
|
||||
onReorder={setCandidates}
|
||||
plateByFile={candidatePlates}
|
||||
onPlateChange={(fileId, plateId) =>
|
||||
setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasEditingVariants && (
|
||||
<VariantCandidates
|
||||
candidates={editingVariants}
|
||||
readOnly
|
||||
readOnlyNote={t('printModal.variants.editNote')}
|
||||
onReorder={() => {}}
|
||||
plateByFile={{}}
|
||||
onPlateChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
|
||||
{!initialSelectedPrinterIds?.length && (
|
||||
{!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
|
||||
<PrinterSelector
|
||||
printers={printers || []}
|
||||
selectedPrinterIds={selectedPrinters}
|
||||
@@ -1364,7 +1564,7 @@ export function PrintModal({
|
||||
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
|
||||
<FilamentOverride
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
availableFilaments={availableFilaments!}
|
||||
availableFilaments={effectiveAvailableFilaments!}
|
||||
overrides={filamentOverrides}
|
||||
onChange={setFilamentOverrides}
|
||||
forceColorMatch={forceColorMatch}
|
||||
@@ -1460,6 +1660,7 @@ export function PrintModal({
|
||||
onForceColorMatchChange={(slotId, value) =>
|
||||
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
|
||||
}
|
||||
archiveAmsMapping={archiveSlicerAmsMapping}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1483,7 +1684,7 @@ export function PrintModal({
|
||||
setEstimatedCostsByPlate((prev) => ({ ...prev, [plateId]: cost }))
|
||||
}
|
||||
budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
|
||||
quantity={effectiveQuantity}
|
||||
quantity={quantityForPlate(plateId)}
|
||||
defaultExpanded={false}
|
||||
currencySymbol={currencySymbol}
|
||||
defaultCostPerKg={defaultCostPerKg}
|
||||
@@ -1491,6 +1692,7 @@ export function PrintModal({
|
||||
onForceColorMatchChange={(slotId, value) =>
|
||||
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
|
||||
}
|
||||
archiveAmsMapping={archiveSlicerAmsMapping}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1513,8 +1715,11 @@ export function PrintModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
|
||||
{mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
|
||||
{/* Quantity — create multiple copies (batch). Hidden for multi-printer
|
||||
selection, and for multi-plate files where the per-plate steppers
|
||||
in PlateSelector own the number instead (#342). */}
|
||||
{mode !== 'edit-queue-item' && !usePerPlateQuantities
|
||||
&& (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
|
||||
{t('queue.quantity', 'Quantity')}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
|
||||
import type { VariantCandidate } from './VariantCandidates';
|
||||
|
||||
export type { CalibrationMode };
|
||||
export type { VariantCandidate };
|
||||
|
||||
/**
|
||||
* Mode of operation for the PrintModal.
|
||||
@@ -38,6 +40,18 @@ export interface PrintModalProps {
|
||||
/** Delete the LibraryFile after dispatch — used by the Printers-page Direct-Print flow
|
||||
* so transient uploads don't linger in File Manager. Only applies to library-file prints. */
|
||||
cleanupLibraryAfterDispatch?: boolean;
|
||||
/**
|
||||
* Cross-model alternatives (#671): the same job sliced for several printers,
|
||||
* to be queued as ONE item that runs on whichever frees up first.
|
||||
*
|
||||
* Supplied by the File Manager when the user multi-selects sliced files, or
|
||||
* when the clicked file belongs to a variant group. Two or more entries put
|
||||
* the modal in cross-model mode: the printer picker is replaced by the ordered
|
||||
* candidate list, and submit posts `variants` instead of a single file.
|
||||
* `libraryFileId` must still be the first candidate — the shared filament and
|
||||
* plate preview reads from it.
|
||||
*/
|
||||
variantFiles?: VariantCandidate[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,6 +197,14 @@ export interface PlateSelectorProps {
|
||||
onDeselectAll?: () => void;
|
||||
/** Whether multi-select (checkboxes) is enabled */
|
||||
multiSelect?: boolean;
|
||||
/**
|
||||
* How many runs of each plate to queue, keyed by plate index (#342). When
|
||||
* provided, each selected plate gets its own quantity control and the
|
||||
* modal's single global Quantity field is hidden — one number per plate is
|
||||
* the whole point, and two controls for the same value would be ambiguous.
|
||||
*/
|
||||
quantities?: Record<number, number>;
|
||||
onQuantityChange?: (plateIndex: number, quantity: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,6 +249,14 @@ export interface FilamentMappingProps {
|
||||
* plate. Each plate prints its own subset of the file's slots and gets its
|
||||
* own AMS mapping, so the panels have to be told apart. */
|
||||
plateLabel?: string;
|
||||
/** The archive's own saved AMS-slot pick from the slicer
|
||||
* (`extra_data.slicer_ams_mapping`, written when the source virtual
|
||||
* printer has "Save AMS mapping" enabled) — position = slot_id-1, value =
|
||||
* global tray ID. When present, a "Mapping" toggle next to "Re-read" lets
|
||||
* the user select every slot from this array instead of the type/color
|
||||
* auto-match. Undefined/omitted when the archive has no saved mapping —
|
||||
* the toggle is hidden and behaviour is unchanged. */
|
||||
archiveAmsMapping?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
DoorClosed,
|
||||
DoorOpen,
|
||||
Droplets,
|
||||
Gauge,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Thermometer,
|
||||
Wind,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { api } from '../api/client';
|
||||
import type { PrinterHASensorReading } from '../api/client';
|
||||
|
||||
/**
|
||||
* The Home Assistant sensors bound to a printer, on its card (#1148, #448).
|
||||
*
|
||||
* Read-only by design — these are contacts and thermometers, not switches, so
|
||||
* nothing here is clickable. The sibling "HA:" row above handles the entities
|
||||
* you can actually operate.
|
||||
*/
|
||||
|
||||
// Home Assistant's own device_class decides the wording, so a door reads
|
||||
// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
|
||||
// absent from this map fall through to on/off, which is what HA itself shows
|
||||
// for a binary_sensor with no class.
|
||||
const BINARY_LABELS: Record<string, { on: string; off: string }> = {
|
||||
door: { on: 'open', off: 'closed' },
|
||||
garage_door: { on: 'open', off: 'closed' },
|
||||
window: { on: 'open', off: 'closed' },
|
||||
opening: { on: 'open', off: 'closed' },
|
||||
lock: { on: 'unlocked', off: 'locked' },
|
||||
motion: { on: 'detected', off: 'clear' },
|
||||
occupancy: { on: 'detected', off: 'clear' },
|
||||
presence: { on: 'detected', off: 'clear' },
|
||||
smoke: { on: 'detected', off: 'clear' },
|
||||
gas: { on: 'detected', off: 'clear' },
|
||||
moisture: { on: 'wet', off: 'dry' },
|
||||
problem: { on: 'problem', off: 'ok' },
|
||||
safety: { on: 'problem', off: 'ok' },
|
||||
running: { on: 'running', off: 'stopped' },
|
||||
};
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
door: DoorOpen,
|
||||
garage_door: DoorOpen,
|
||||
window: DoorOpen,
|
||||
opening: DoorOpen,
|
||||
lock: LockOpen,
|
||||
temperature: Thermometer,
|
||||
humidity: Droplets,
|
||||
moisture: Droplets,
|
||||
motion: Activity,
|
||||
occupancy: Activity,
|
||||
presence: Activity,
|
||||
smoke: AlertTriangle,
|
||||
gas: AlertTriangle,
|
||||
problem: AlertTriangle,
|
||||
safety: AlertTriangle,
|
||||
running: Wind,
|
||||
};
|
||||
|
||||
function iconFor(reading: PrinterHASensorReading): LucideIcon {
|
||||
const deviceClass = reading.device_class ?? '';
|
||||
// A closed door wants the closed-door glyph — the map is keyed by class, so
|
||||
// the two states that have a distinct "off" icon are special-cased here.
|
||||
if (reading.state === 'off') {
|
||||
if (ICONS[deviceClass] === DoorOpen) return DoorClosed;
|
||||
if (ICONS[deviceClass] === LockOpen) return Lock;
|
||||
}
|
||||
return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
printerId: number;
|
||||
}
|
||||
|
||||
export function PrinterHASensorRow({ printerId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: readings } = useQuery({
|
||||
queryKey: ['haSensorReadings', printerId],
|
||||
queryFn: () => api.getHASensorReadings(printerId),
|
||||
// Served from the backend poller's cache, so this costs a local request
|
||||
// and never a Home Assistant round trip. Matched to the poller's own
|
||||
// cadence — refetching faster would only re-read the same reading.
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
if (!readings?.length) return null;
|
||||
|
||||
const describe = (reading: PrinterHASensorReading): string => {
|
||||
if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
|
||||
if (reading.kind === 'numeric') {
|
||||
if (reading.value === null) return reading.state;
|
||||
return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value);
|
||||
}
|
||||
const labels = BINARY_LABELS[reading.device_class ?? ''];
|
||||
const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
|
||||
return t(`haSensors.states.${key}`, { defaultValue: key });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
|
||||
<div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{readings.map((reading) => {
|
||||
const Icon = iconFor(reading);
|
||||
const unreachable = !reading.reachable || reading.state === null;
|
||||
return (
|
||||
<span
|
||||
key={reading.id}
|
||||
title={
|
||||
reading.block_print
|
||||
? t('haSensors.blocksPrints', { entity: reading.entity_id })
|
||||
: reading.entity_id
|
||||
}
|
||||
className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
|
||||
unreachable
|
||||
? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
|
||||
: reading.alerting
|
||||
? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
|
||||
<span>{reading.name}</span>
|
||||
<span className="font-medium">{describe(reading)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Copy, Check, Signal, Cable } from 'lucide-react';
|
||||
import { X, Signal, Cable } from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { formatDateOnly } from '../utils/date';
|
||||
import { getPrinterImage, getWifiStrength } from '../utils/printer';
|
||||
import type { Printer, PrinterStatus } from '../api/client';
|
||||
@@ -13,53 +14,6 @@ interface PrinterInfoModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
// navigator.clipboard is gated by the secure-context requirement, so on
|
||||
// plain-HTTP LAN deployments (#1174) the API is undefined and the previous
|
||||
// code silently swallowed the failure — the icon never flipped to the tick
|
||||
// and nothing landed on the user's clipboard. Fall back to the legacy
|
||||
// execCommand path via an off-screen textarea, matching the pattern used
|
||||
// by CameraTokensPage's plaintext-token modal.
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
if (!ok) return;
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Both paths failed (no clipboard API, no execCommand). Leave the icon
|
||||
// unchanged so the user knows nothing was copied.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
import { filterCompatibleQueueItems } from '../utils/printer';
|
||||
import { queueItemDisplayName } from '../utils/queueItemName';
|
||||
|
||||
interface PrinterQueueWidgetProps {
|
||||
printerId: number;
|
||||
@@ -53,7 +54,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
|
||||
<p className="text-sm text-white truncate">
|
||||
{nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
|
||||
{nextItem ? queueItemDisplayName(nextItem) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +237,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
// see canUseEmbedded below.
|
||||
const [useEmbedded, setUseEmbedded] = useState(false);
|
||||
|
||||
// Auto-orient / auto-arrange (#2548) — the GUI's two layout buttons,
|
||||
// forwarded as the slicer's --orient / --arrange CLI actions. Per-slice
|
||||
// and off by default: both rewrite the object placement the file came
|
||||
// with, so they are something the user asks for, never a default. Kept
|
||||
// enabled in embedded mode, unlike the process-level options around
|
||||
// them — these act on the geometry, whichever config drives the slice.
|
||||
const [autoOrient, setAutoOrient] = useState(false);
|
||||
const [autoArrange, setAutoArrange] = useState(false);
|
||||
|
||||
// #2622: process settings the designer changed away from the stock preset,
|
||||
// carried onto the picked process profile so a cross-printer re-slice keeps
|
||||
// the model's intended wall count / infill / first layer instead of losing
|
||||
@@ -544,6 +553,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
// which the embedded-settings path never sends — so they are mutually
|
||||
// exclusive by construction (#2622).
|
||||
...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
|
||||
// Sent only when on. The backend defaults both to false, so omitting
|
||||
// them keeps the request identical to what older clients send.
|
||||
...(autoOrient ? { auto_orient: true } : {}),
|
||||
...(autoArrange ? { auto_arrange: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -888,6 +901,42 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
onChange={setBedType}
|
||||
disabled={isEnqueuing || useEmbedded}
|
||||
/>
|
||||
{/* Layout passes (#2548) — the GUI's "Auto orient" / "Auto
|
||||
arrange". Not disabled in embedded mode: these are CLI
|
||||
actions on the geometry, so they work regardless of where
|
||||
the print config comes from. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoOrient}
|
||||
onChange={(e) => setAutoOrient(e.target.checked)}
|
||||
disabled={isEnqueuing}
|
||||
className="mt-0.5 cursor-pointer"
|
||||
/>
|
||||
<span>
|
||||
{t('slice.autoOrient')}
|
||||
<span className="block text-xs text-bambu-gray/70">
|
||||
{t('slice.autoOrientHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoArrange}
|
||||
onChange={(e) => setAutoArrange(e.target.checked)}
|
||||
disabled={isEnqueuing}
|
||||
className="mt-0.5 cursor-pointer"
|
||||
/>
|
||||
<span>
|
||||
{t('slice.autoArrange')}
|
||||
<span className="block text-xs text-bambu-gray/70">
|
||||
{t('slice.autoArrangeHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Filament reqs may need a server-side preview-slice for
|
||||
unsliced project files (single-pass, then cached). Show a
|
||||
scoped spinner so the user sees the printer/process
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Streaming-overlay URL builder (#1422).
|
||||
*
|
||||
* The overlay at /overlay/{printerId} has been configurable by query string
|
||||
* since #2613, but only for people who found the parameters in the wiki. The
|
||||
* issue asked for the field set to be selectable "through the web UI"; this is
|
||||
* that surface. It composes a URL, it does not persist anything — the URL *is*
|
||||
* the configuration, which keeps a scene in OBS reproducible by copy-paste and
|
||||
* means two displays can show different fields off one token.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
|
||||
import { api, type Printer } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
type OverlaySize = 'small' | 'medium' | 'large';
|
||||
|
||||
// Order matters: it is the order the fields appear in the overlay, so the
|
||||
// checkbox list reads as a preview of the result.
|
||||
const FIELDS = [
|
||||
{ key: 'printer', labelKey: 'streamOverlay.builder.fieldPrinter', fallback: 'Printer name' },
|
||||
{ key: 'filename', labelKey: 'streamOverlay.builder.fieldFilename', fallback: 'File name' },
|
||||
{ key: 'status', labelKey: 'streamOverlay.builder.fieldStatus', fallback: 'Status' },
|
||||
{ key: 'progress', labelKey: 'streamOverlay.builder.fieldProgress', fallback: 'Progress bar' },
|
||||
{ key: 'layers', labelKey: 'streamOverlay.builder.fieldLayers', fallback: 'Layer count' },
|
||||
{ key: 'eta', labelKey: 'streamOverlay.builder.fieldEta', fallback: 'Time remaining and ETA' },
|
||||
{ key: 'nozzle', labelKey: 'printers.heaterHistory.nozzle', fallback: 'Nozzle' },
|
||||
{ key: 'bed', labelKey: 'printers.heaterHistory.bed', fallback: 'Bed' },
|
||||
{ key: 'chamber', labelKey: 'printers.heaterHistory.chamber', fallback: 'Chamber' },
|
||||
] as const;
|
||||
|
||||
// Matches parseConfig() in StreamOverlayPage: the fields an overlay shows when
|
||||
// the URL carries no ?show= at all.
|
||||
const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status'];
|
||||
|
||||
const DEFAULT_FPS = 15;
|
||||
|
||||
export function StreamOverlayBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [printers, setPrinters] = useState<Printer[]>([]);
|
||||
const [printerId, setPrinterId] = useState<number | null>(null);
|
||||
const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
|
||||
const [size, setSize] = useState<OverlaySize>('medium');
|
||||
const [fps, setFps] = useState(DEFAULT_FPS);
|
||||
const [showCamera, setShowCamera] = useState(true);
|
||||
const [token, setToken] = useState('');
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await api.getPrinters();
|
||||
if (cancelled) return;
|
||||
setPrinters(list);
|
||||
if (list.length > 0) setPrinterId(list[0].id);
|
||||
} catch {
|
||||
// A failed printer list only costs the picker its options — the builder
|
||||
// still works if the user types a printer number into the URL by hand,
|
||||
// so this is not worth a toast on a settings page they may just be
|
||||
// scrolling past.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const url = useMemo(() => {
|
||||
const id = printerId ?? 1;
|
||||
const params = new URLSearchParams();
|
||||
// Emit ?show= in the canonical field order rather than click order, so the
|
||||
// same selection always produces the same URL.
|
||||
const selected = FIELDS.filter((f) => fields.includes(f.key)).map((f) => f.key);
|
||||
params.set('show', selected.join(','));
|
||||
if (size !== 'medium') params.set('size', size);
|
||||
if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
|
||||
if (!showCamera) params.set('camera', 'false');
|
||||
if (token.trim()) params.set('token', token.trim());
|
||||
return `${window.location.origin}/overlay/${id}?${params.toString()}`;
|
||||
}, [printerId, fields, size, fps, showCamera, token]);
|
||||
|
||||
const toggleField = (key: string) => {
|
||||
setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
|
||||
};
|
||||
|
||||
const copyUrl = async () => {
|
||||
try {
|
||||
// Same fallback as the token dialog: the clipboard API needs a secure
|
||||
// context, and plenty of Bambuddy installs are plain HTTP on a LAN.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
|
||||
} catch {
|
||||
showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
{t(
|
||||
'streamOverlay.builder.description',
|
||||
'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="overlay-builder-printer"
|
||||
className="block text-sm font-medium text-white mb-1"
|
||||
>
|
||||
{t('streamOverlay.builder.printer', 'Printer')}
|
||||
</label>
|
||||
<select
|
||||
id="overlay-builder-printer"
|
||||
value={printerId ?? ''}
|
||||
onChange={(e) => setPrinterId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{printers.length === 0 && <option value="">{t('common.loading', 'Loading…')}</option>}
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="overlay-builder-size" className="block text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.size', 'Text size')}
|
||||
</label>
|
||||
<select
|
||||
id="overlay-builder-size"
|
||||
value={size}
|
||||
onChange={(e) => setSize(e.target.value as OverlaySize)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="small">{t('streamOverlay.builder.sizeSmall', 'Small')}</option>
|
||||
<option value="medium">{t('streamOverlay.builder.sizeMedium', 'Medium')}</option>
|
||||
<option value="large">{t('streamOverlay.builder.sizeLarge', 'Large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.fps', 'Frame rate')}
|
||||
</label>
|
||||
<input
|
||||
id="overlay-builder-fps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Math.min(Math.max(Number(e.target.value) || 1, 1), 30))}
|
||||
className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t(
|
||||
'streamOverlay.builder.fpsHint',
|
||||
'A1 and P1 cameras top out around 5 fps whatever you ask for.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="overlay-builder-token" className="block text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.token', 'Streaming Overlay token (optional)')}
|
||||
</label>
|
||||
<input
|
||||
id="overlay-builder-token"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="bblt_…"
|
||||
className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t(
|
||||
'streamOverlay.builder.tokenHint',
|
||||
'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="text-sm font-medium text-white mb-2">
|
||||
{t('streamOverlay.builder.fields', 'Fields to show')}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{FIELDS.map((field) => (
|
||||
<label key={field.key} className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.includes(field.key)}
|
||||
onChange={() => toggleField(field.key)}
|
||||
className="accent-bambu-green"
|
||||
/>
|
||||
{t(field.labelKey, field.fallback)}
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCamera}
|
||||
onChange={(e) => setShowCamera(e.target.checked)}
|
||||
className="accent-bambu-green"
|
||||
/>
|
||||
{t('streamOverlay.builder.fieldCamera', 'Camera feed')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{t(
|
||||
'streamOverlay.builder.chamberHint',
|
||||
'Chamber temperature only appears on models with a real chamber sensor — P1 and A1 printers report a meaningless value, so it is left out there.',
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.urlTitle', 'Overlay URL')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
|
||||
{url}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyUrl()}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('cameraTokens.created.copy', 'Copy')}
|
||||
</button>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('streamOverlay.builder.open', 'Open')}
|
||||
</a>
|
||||
</div>
|
||||
{token.trim() && (
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{t(
|
||||
'streamOverlay.builder.tokenWarning',
|
||||
'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The preview opens a real camera stream, so it stays off until asked
|
||||
for. Leaving one running behind a settings tab would hold a subscriber
|
||||
on the printer's single camera connection for as long as the tab is
|
||||
open. */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((p) => !p)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 text-sm"
|
||||
>
|
||||
{preview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{preview
|
||||
? t('streamOverlay.builder.hidePreview', 'Hide preview')
|
||||
: t('streamOverlay.builder.showPreview', 'Show preview')}
|
||||
</button>
|
||||
{preview && (
|
||||
<iframe
|
||||
key={url}
|
||||
src={url}
|
||||
title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
|
||||
className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
const [localModel, setLocalModel] = useState(printer.model || '');
|
||||
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
|
||||
const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
|
||||
const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
|
||||
const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
|
||||
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
setLocalModel(printer.model || '');
|
||||
setLocalAutoDispatch(printer.auto_dispatch ?? true);
|
||||
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
|
||||
setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
|
||||
setLocalGcodeInjection(printer.gcode_injection ?? false);
|
||||
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
|
||||
}
|
||||
@@ -133,6 +135,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
setLocalTargetPrinterId(printer.target_printer_id);
|
||||
setLocalBindIp(printer.bind_ip || '');
|
||||
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
|
||||
// Queue-mode behaviour toggles. Without these the switch stays visually
|
||||
// flipped after a failed save, so the card claims a setting the server
|
||||
// never accepted.
|
||||
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
|
||||
setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
|
||||
setLocalGcodeInjection(printer.gcode_injection ?? false);
|
||||
setPendingAction(null);
|
||||
},
|
||||
});
|
||||
@@ -439,6 +447,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save-AMS-mapping toggle - only for queue mode */}
|
||||
{localMode === 'queue' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm font-medium">{t('virtualPrinter.saveAmsMapping.title')}</div>
|
||||
<div className="text-[10px] text-bambu-gray">{t('virtualPrinter.saveAmsMapping.description')}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newVal = !localSaveAmsMapping;
|
||||
setLocalSaveAmsMapping(newVal);
|
||||
setPendingAction('saveAmsMapping');
|
||||
updateMutation.mutate({ save_ams_mapping: newVal });
|
||||
}}
|
||||
disabled={pendingAction === 'saveAmsMapping'}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
|
||||
localSaveAmsMapping ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${pendingAction === 'saveAmsMapping' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
localSaveAmsMapping ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* G-code injection toggle - only for queue mode (#1516) */}
|
||||
{localMode === 'queue' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
|
||||
Reference in New Issue
Block a user