Merge remote-tracking branch 'upstream/dev' into feature/billing

This commit is contained in:
behrinml
2026-08-05 20:19:32 +02:00
215 changed files with 28788 additions and 2152 deletions
@@ -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') });
+345
View File
@@ -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>
);
}
+72 -16
View File
@@ -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);
+72
View File
@@ -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">
+413
View File
@@ -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>
);
}
+269 -113
View File
@@ -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
+30 -1
View File
@@ -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>
);
}
+140 -21
View File
@@ -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;
}
+226 -21
View File
@@ -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>
);
}
+3 -49
View File
@@ -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>
+49
View File
@@ -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">