Merge branch '0.1.8b' into feature/updated_plate_view

This commit is contained in:
MartinNYHC
2026-02-03 17:15:21 +01:00
committed by GitHub
120 changed files with 24410 additions and 3206 deletions
+43 -6
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
import { Button } from './Button';
@@ -77,8 +77,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
// Switchbar visibility
// Visibility options
const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
// Discovery state
const [isScanning, setIsScanning] = useState(false);
@@ -251,6 +252,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
// Also invalidate printer card HA entity queries
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
onClose();
},
onError: (err: Error) => {
@@ -263,6 +266,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
// Also invalidate printer card HA entity queries
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
onClose();
},
onError: (err: Error) => {
@@ -270,10 +275,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
},
});
// Filter out printers that already have a plug assigned (except current plug's printer)
// For Tasmota plugs, only one per printer (physical device)
// For HA scripts, allow multiple per printer
const availablePrinters = printers?.filter(p => {
const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
return !hasPlug;
if (plugType === 'tasmota') {
const hasTasmotaPlug = existingPlugs?.some(
ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'
);
return !hasTasmotaPlug;
}
// HA scripts can have multiple per printer
return true;
});
const handleSubmit = (e: React.FormEvent) => {
@@ -339,8 +351,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
schedule_enabled: scheduleEnabled,
schedule_on_time: scheduleOnTime || null,
schedule_off_time: scheduleOffTime || null,
// Switchbar
// Visibility
show_in_switchbar: showInSwitchbar,
show_on_printer_card: showOnPrinterCard,
};
if (isEditing) {
@@ -1308,6 +1321,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
</div>
{/* Printer Card Visibility - only for HA entities */}
{plugType === 'homeassistant' && (
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-bambu-green" />
<div>
<span className="text-white font-medium">Show on Printer Card</span>
<p className="text-xs text-bambu-gray">Display button on printer card</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={showOnPrinterCard}
onChange={(e) => setShowOnPrinterCard(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
+10 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -18,14 +19,18 @@ interface ConfirmModalProps {
export function ConfirmModal({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText,
cancelText,
variant = 'default',
isLoading = false,
loadingText,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const { t } = useTranslation();
const resolvedConfirmText = confirmText ?? t('common.confirm');
const resolvedCancelText = cancelText ?? t('common.cancel');
const resolvedLoadingText = loadingText ?? t('common.loading');
// Close on Escape key (but not while loading)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -70,7 +75,7 @@ export function ConfirmModal({
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
{cancelText}
{resolvedCancelText}
</Button>
<Button
onClick={onConfirm}
@@ -80,10 +85,10 @@ export function ConfirmModal({
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{loadingText || 'Processing...'}
{resolvedLoadingText}
</>
) : (
confirmText
resolvedConfirmText
)}
</Button>
</div>
+49 -49
View File
@@ -1,30 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
const FAILURE_REASONS = [
'Adhesion failure',
'Spaghetti / Detached',
'Layer shift',
'Clogged nozzle',
'Filament runout',
'Warping',
'Stringing',
'Under-extrusion',
'Power failure',
'User cancelled',
'Other',
];
// Keys for failure reasons - translated at render time
const FAILURE_REASON_KEYS = [
'adhesionFailure',
'spaghettiDetached',
'layerShift',
'cloggedNozzle',
'filamentRunout',
'warping',
'stringing',
'underExtrusion',
'powerFailure',
'userCancelled',
'other',
] as const;
const ARCHIVE_STATUSES = [
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'aborted', label: 'Cancelled' },
{ value: 'printing', label: 'Printing' },
];
// Keys for archive statuses - translated at render time
const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
interface EditArchiveModalProps {
archive: Archive;
@@ -33,6 +31,8 @@ interface EditArchiveModalProps {
}
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
const { t } = useTranslation();
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">Edit Archive</h2>
<h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
@@ -218,25 +218,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Print Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
<input
type="text"
value={printName}
onChange={(e) => setPrintName(e.target.value)}
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"
placeholder="Print name"
placeholder={t('editArchive.namePlaceholder')}
/>
</div>
{/* Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
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="">No printer</option>
<option value="">{t('editArchive.noPrinter')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -249,14 +249,14 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<FolderKanban className="w-4 h-4 inline mr-1" />
Project
{t('editArchive.project')}
</label>
<select
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
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="">No project</option>
<option value="">{t('editArchive.noProject')}</option>
{projects?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -269,7 +269,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Hash className="w-4 h-4 inline mr-1" />
Items Printed
{t('editArchive.itemsPrinted')}
</label>
<input
type="number"
@@ -280,19 +280,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
placeholder="1"
/>
<p className="text-xs text-bambu-gray mt-1">
Number of items produced in this print job
{t('editArchive.itemsPrintedHelp')}
</p>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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 resize-none"
placeholder="Add notes about this print..."
placeholder={t('editArchive.notesPlaceholder')}
/>
</div>
@@ -300,7 +300,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Link className="w-4 h-4 inline mr-1" />
External Link
{t('editArchive.externalLink')}
</label>
<input
type="url"
@@ -310,13 +310,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
placeholder="https://printables.com/model/..."
/>
<p className="text-xs text-bambu-gray mt-1">
Link to Printables, Thingiverse, or other source
{t('editArchive.externalLinkHelp')}
</p>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
{/* Current tags as chips */}
{currentTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -355,13 +355,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
}}
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"
placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
/>
{/* Suggestions dropdown */}
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
{currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
@@ -382,7 +382,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{/* Status */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Status</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
<select
value={status}
onChange={(e) => {
@@ -394,9 +394,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
}}
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"
>
{ARCHIVE_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
{ARCHIVE_STATUS_KEYS.map((statusKey) => (
<option key={statusKey} value={statusKey}>
{t(`editArchive.statuses.${statusKey}`)}
</option>
))}
</select>
@@ -405,16 +405,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{/* Failure Reason - only show for failed/aborted prints */}
{(status === 'failed' || status === 'aborted') && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
<select
value={failureReason}
onChange={(e) => setFailureReason(e.target.value)}
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="">Select reason...</option>
{FAILURE_REASONS.map((reason) => (
<option key={reason} value={reason}>
{reason}
<option value="">{t('editArchive.selectReason')}</option>
{FAILURE_REASON_KEYS.map((reasonKey) => (
<option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
{t(`editArchive.failureReasons.${reasonKey}`)}
</option>
))}
</select>
@@ -425,7 +425,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
Photos of Printed Result
{t('editArchive.photos')}
</label>
{/* Photo grid */}
<div className="flex flex-wrap gap-2 mb-2">
@@ -433,7 +433,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div key={filename} className="relative group">
<img
src={api.getArchivePhotoUrl(archive.id, filename)}
alt="Print result"
alt={t('editArchive.printResult')}
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
/>
<button
@@ -462,7 +462,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
)}
</label>
</div>
<p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
<p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
</div>
{/* Actions */}
@@ -473,7 +473,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
onClick={onClose}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
@@ -481,7 +481,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
className="flex-1"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save'}
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
@@ -184,17 +184,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
stallCheckIntervalRef.current = setInterval(async () => {
try {
const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
if (response.ok) {
const status = await response.json();
if (status.stalled || (!status.active && !streamError)) {
if (stallCheckIntervalRef.current) {
clearInterval(stallCheckIntervalRef.current);
stallCheckIntervalRef.current = null;
}
setStreamLoading(false);
attemptReconnect();
const status = await api.getCameraStatus(printerId);
if (status.stalled || (!status.active && !streamError)) {
if (stallCheckIntervalRef.current) {
clearInterval(stallCheckIntervalRef.current);
stallCheckIntervalRef.current = null;
}
setStreamLoading(false);
attemptReconnect();
}
} catch {
// Ignore errors
+34 -14
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
interface FilamentData {
vendor: 'Bambu Lab' | 'Generic';
@@ -15,6 +16,8 @@ interface SpoolmanConfig {
enabled: boolean;
onLinkSpool?: (trayUuid: string) => void;
hasUnlinkedSpools?: boolean; // Whether there are spools available to link
linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
}
interface ConfigureSlotConfig {
@@ -36,6 +39,7 @@ interface FilamentHoverCardProps {
* Replaces the basic browser tooltip with a styled popover.
*/
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
const [copied, setCopied] = useState(false);
@@ -199,7 +203,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* Profile name */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
Profile
{t('ams.profile')}
</span>
<span className="text-xs text-white font-semibold truncate max-w-[120px]">
{data.profile}
@@ -209,7 +213,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* K Factor */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
K Factor
{t('ams.kFactor')}
</span>
<span className="text-xs text-bambu-green font-mono font-bold">
{data.kFactor}
@@ -221,7 +225,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
<Droplets className="w-3 h-3" />
Fill
{t('ams.fill')}
</span>
<span className="text-xs text-white font-semibold">
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
@@ -249,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* Tray UUID with copy button */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
Spool ID
{t('spoolman.spoolId')}
</span>
<button
onClick={(e) => {
@@ -270,8 +274,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
</button>
</div>
{/* Link Spool button */}
{spoolman.onLinkSpool && (
{/* Open in Spoolman button (when already linked) */}
{spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
<a
href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
title={t('spoolman.openInSpoolman')}
>
<ExternalLink className="w-3.5 h-3.5" />
{t('spoolman.openInSpoolman')}
</a>
)}
{/* Link Spool button (when not linked) */}
{!spoolman.linkedSpoolId && spoolman.onLinkSpool && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -285,10 +304,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
: 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
}`}
title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
>
<Link2 className="w-3.5 h-3.5" />
Link to Spoolman
{t('spoolman.linkToSpoolman')}
</button>
)}
</div>
@@ -303,10 +322,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
title={t('ams.configureSlot')}
>
<Settings2 className="w-3.5 h-3.5" />
Configure
{t('ams.configure')}
</button>
</div>
)}
@@ -340,6 +359,7 @@ interface EmptySlotHoverCardProps {
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
*/
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -377,7 +397,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
rounded-md shadow-lg overflow-hidden
">
<div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
Empty slot
{t('ams.emptySlot')}
</div>
{/* Configure slot button */}
{configureSlot?.enabled && (
@@ -388,10 +408,10 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
title={t('ams.configureSlot')}
>
<Settings2 className="w-3.5 h-3.5" />
Configure
{t('ams.configure')}
</button>
</div>
)}
+16 -12
View File
@@ -61,9 +61,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
const qty = archive.quantity || 1;
existing.filament += (archive.filament_used_grams || 0) * qty;
existing.cost += archive.cost || 0;
existing.prints += 1;
existing.prints += qty;
dataMap.set(key, existing);
});
@@ -89,9 +90,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
const qty = archive.quantity || 1;
existing.filament += (archive.filament_used_grams || 0) * qty;
existing.cost += archive.cost || 0;
existing.prints += 1;
existing.prints += qty;
dataMap.set(key, existing);
});
@@ -110,10 +112,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
filteredArchives.forEach(archive => {
const type = archive.filament_type || 'Unknown';
const qty = archive.quantity || 1;
// Handle multiple types (e.g., "PLA, PETG")
const types = type.split(', ');
types.forEach(t => {
const grams = (archive.filament_used_grams || 0) / types.length;
const grams = ((archive.filament_used_grams || 0) * qty) / types.length;
dataMap.set(t, (dataMap.get(t) || 0) + grams);
});
});
@@ -140,9 +143,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
months.push({
month: monthStr,
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0)),
cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
prints: monthArchives.length,
prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
});
}
@@ -150,8 +153,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
}, [archives]);
const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0);
const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
return (
<div className="space-y-6">
@@ -185,17 +189,17 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Period Cost</p>
<p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
<p className="text-xs text-bambu-gray">{filteredArchives.length} prints</p>
<p className="text-xs text-bambu-gray">{totalPrints} prints</p>
</div>
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Avg per Print</p>
<p className="text-2xl font-bold text-white">
{filteredArchives.length > 0
? (totalFilament / filteredArchives.length).toFixed(0)
{totalPrints > 0
? (totalFilament / totalPrints).toFixed(0)
: 0}g
</p>
<p className="text-xs text-bambu-gray">
{currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg
{currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg
</p>
</div>
</div>
+17 -13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
X,
Folder,
@@ -285,6 +286,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
];
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState('/');
@@ -323,13 +325,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
}
},
onSuccess: () => {
showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
setSelectedFiles(new Set());
setFilesToDelete([]);
},
onError: (error: Error) => {
showToast(`Delete failed: ${error.message}`, 'error');
showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');
},
});
@@ -377,8 +379,10 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
const paths = Array.from(selectedFiles);
if (paths.length === 1) {
// Single file - direct download
window.open(api.getPrinterFileDownloadUrl(printerId, paths[0]), '_blank');
// Single file - direct download with auth
api.downloadPrinterFile(printerId, paths[0]).catch((err) => {
console.error('Printer file download failed:', err);
});
setSelectedFiles(new Set());
return;
}
@@ -431,7 +435,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-bambu-green" />
<div>
<h2 className="text-lg font-semibold text-white">File Manager</h2>
<h2 className="text-lg font-semibold text-white">{t('printerFiles.title')}</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
</div>
@@ -440,13 +444,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
{storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
<div className="text-sm text-bambu-gray flex items-center gap-2">
{storageData.used_bytes != null && (
<span>Used: {formatStorageSize(storageData.used_bytes)}</span>
<span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
)}
{storageData.used_bytes != null && storageData.free_bytes != null && (
<span className="text-bambu-dark-tertiary">|</span>
)}
{storageData.free_bytes != null && (
<span>Free: {formatStorageSize(storageData.free_bytes)}</span>
<span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
)}
</div>
)}
@@ -484,7 +488,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Filter files..."
placeholder={t('printerFiles.filterPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
@@ -705,7 +709,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
) : (
<Trash2 className="w-4 h-4" />
)}
Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
{t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
</Button>
</div>
</div>
@@ -714,13 +718,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
{/* Delete Confirmation Modal */}
{filesToDelete.length > 0 && (
<ConfirmModal
title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}
message={
filesToDelete.length > 1
? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
: `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })
: t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })
}
confirmText="Delete"
confirmText={t('common.delete')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate(filesToDelete);
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Github,
@@ -96,6 +97,7 @@ function formatRelativeTime(dateStr: string | null): string {
export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { t } = useTranslation();
// Local state for form
const [repoUrl, setRepoUrl] = useState('');
@@ -762,9 +764,12 @@ export function GitHubBackupSettings() {
{/* Import */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Restore Backup</p>
<p className="text-white">{t('backup.restoreBackup')}</p>
<p className="text-sm text-bambu-gray">
Replace all data from a backup file
{t('backup.restoreDescription')}
</p>
<p className="text-xs text-bambu-gray-light mt-1">
{t('backup.restoreNote')}
</p>
</div>
<input
+7 -4
View File
@@ -1,6 +1,7 @@
// HMS Error Modal - Comprehensive error code database
// Source: https://github.com/greghesp/ha-bambulab
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
import type { HMSError } from '../api/client';
@@ -904,6 +905,8 @@ function getHMSHomeUrl(): string {
}
export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
const { t } = useTranslation();
// Debug: log errors to see what data we're receiving
console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
@@ -930,7 +933,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-400" />
<h2 className="text-lg font-semibold text-white">Errors - {printerName}</h2>
<h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
</div>
<button
onClick={onClose}
@@ -945,7 +948,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
{knownErrors.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
<AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No errors</p>
<p>{t('hmsErrors.noErrors')}</p>
</div>
) : (
<div className="space-y-3">
@@ -979,7 +982,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
>
<ExternalLink className="w-3 h-3" />
View on Bambu Lab Wiki
{t('hmsErrors.viewOnWiki')}
</a>
</div>
</div>
@@ -993,7 +996,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
{/* Footer */}
<div className="p-4 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray">
Clear errors on the printer to dismiss them here.
{t('hmsErrors.clearInstructions')}
</p>
</div>
</div>
+97 -95
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Gauge,
Loader2,
@@ -171,6 +172,7 @@ function KProfileModal({
onSaveNote,
hasPermission,
}: KProfileModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [name, setName] = useState(profile?.name || '');
@@ -219,7 +221,7 @@ function KProfileModal({
},
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
showToast(t('kProfiles.toast.profileSaved'));
// Save note if it changed (including clearing it)
if (onSaveNote && note !== initialNote) {
let profileKey: string;
@@ -258,7 +260,7 @@ function KProfileModal({
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast('K-profile deleted');
showToast(t('kProfiles.toast.profileDeleted'));
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add longer delay for delete - printer needs more time to process
@@ -294,7 +296,7 @@ function KProfileModal({
// Validate at least one extruder is selected for dual-nozzle
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
showToast('Please select at least one extruder', 'error');
showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
return;
}
@@ -340,7 +342,7 @@ function KProfileModal({
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${filamentId}`;
@@ -348,7 +350,7 @@ function KProfileModal({
}
} catch (error) {
console.error('[KProfile] Failed to save batch:', error);
showToast('Failed to save K-profiles', 'error');
showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
@@ -373,16 +375,16 @@ function KProfileModal({
<Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">
{savingProgress.total > 1
? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
: 'Syncing with printer...'}
? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
: t('kProfiles.modal.syncing')}
</p>
<p className="text-bambu-gray text-sm mt-1">Please wait</p>
<p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
</div>
)}
<CardContent className="p-0">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
{profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
</h2>
<button
onClick={onClose}
@@ -396,21 +398,21 @@ function KProfileModal({
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Profile Name - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
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' : ''}`}
placeholder="My PLA Profile"
placeholder={t('kProfiles.modal.profileNamePlaceholder')}
required={!profile}
/>
</div>
{/* K-Value - always editable */}
<div>
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
<input
type="text"
inputMode="decimal"
@@ -430,17 +432,17 @@ function KProfileModal({
}
}}
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 font-mono"
placeholder="0.020"
placeholder={t('kProfiles.modal.kValuePlaceholder')}
required
/>
<p className="text-xs text-bambu-gray mt-1">
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
{t('kProfiles.modal.kValueHelp')}
</p>
</div>
{/* Filament - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
<select
value={filamentId}
onChange={(e) => {
@@ -460,7 +462,7 @@ function KProfileModal({
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="">Select filament...</option>
<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}>
@@ -476,7 +478,7 @@ function KProfileModal({
</select>
{!profile && knownFilaments.length === 0 && (
<p className="text-xs text-bambu-gray mt-1">
No filaments found. Create a K-profile in Bambu Studio first.
{t('kProfiles.modal.noFilamentsHelp')}
</p>
)}
</div>
@@ -484,7 +486,7 @@ function KProfileModal({
{/* Flow Type and Nozzle Size - read-only when editing */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
<select
value={nozzleType}
onChange={(e) => {
@@ -503,12 +505,12 @@ 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">High Flow</option>
<option value="HS00">Standard</option>
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
<option value="HS00">{t('kProfiles.modal.standard')}</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
<select
value={modalDiameter}
onChange={(e) => setModalDiameter(e.target.value)}
@@ -527,12 +529,12 @@ function KProfileModal({
{isDualNozzle && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
{profile ? 'Extruder' : 'Extruders'}
{profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
</label>
{profile ? (
// Read-only display for editing
<div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
{profile.extruder_id === 1 ? 'Left' : 'Right'}
{profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
</div>
) : (
// Checkboxes for new profile - can select both
@@ -550,7 +552,7 @@ function KProfileModal({
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Left</span>
<span className="text-white">{t('kProfiles.modal.left')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -565,7 +567,7 @@ function KProfileModal({
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Right</span>
<span className="text-white">{t('kProfiles.modal.right')}</span>
</label>
</div>
)}
@@ -574,16 +576,16 @@ function KProfileModal({
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add notes about this profile..."
placeholder={t('kProfiles.modal.notesPlaceholder')}
rows={2}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
/>
<p className="text-xs text-bambu-gray mt-1">
Notes are saved in Bambuddy, not on the printer
{t('kProfiles.modal.notesHelp')}
</p>
</div>
@@ -594,7 +596,7 @@ function KProfileModal({
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
@@ -611,12 +613,12 @@ function KProfileModal({
disabled={isSyncing}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
className="flex-1"
>
{saveMutation.isPending ? (
@@ -624,7 +626,7 @@ function KProfileModal({
) : (
<Gauge className="w-4 h-4" />
)}
Save
{t('common.save')}
</Button>
</div>
</form>
@@ -641,12 +643,12 @@ function KProfileModal({
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
<h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
<p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
{t('kProfiles.deleteConfirm.message', { name: profile?.name })}
</p>
<div className="flex gap-3">
<Button
@@ -654,7 +656,7 @@ function KProfileModal({
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={() => {
@@ -669,7 +671,7 @@ function KProfileModal({
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
{t('common.delete')}
</Button>
</div>
</CardContent>
@@ -691,6 +693,7 @@ const STORAGE_KEYS = {
};
export function KProfilesView() {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
@@ -882,7 +885,7 @@ export function KProfilesView() {
// Export profiles to JSON file
const handleExport = useCallback(() => {
if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
showToast('No profiles to export', 'error');
showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
return;
}
@@ -910,8 +913,8 @@ export function KProfilesView() {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${kprofiles.profiles.length} profiles`);
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
// Import profiles from JSON file
const handleImport = useCallback(() => {
@@ -927,7 +930,7 @@ export function KProfilesView() {
const data = JSON.parse(text);
if (!data.profiles || !Array.isArray(data.profiles)) {
showToast('Invalid file format', 'error');
showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
return;
}
@@ -954,15 +957,15 @@ export function KProfilesView() {
}
}
showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
refetchProfiles();
} catch (err) {
console.error('Import error:', err);
showToast('Failed to parse import file', 'error');
showToast(t('kProfiles.toast.failedToParseImport'), 'error');
}
};
input.click();
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
// Toggle profile selection using composite key
const toggleProfileSelection = useCallback((profileKey: string) => {
@@ -1012,13 +1015,13 @@ export function KProfilesView() {
}
}
showToast(`Deleted ${deleted} profiles`);
showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
setBulkDeleteInProgress(false);
setShowBulkDeleteConfirm(false);
setSelectionMode(false);
setSelectedProfiles(new Set());
refetchProfiles();
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
// Generate possible keys for a profile (for notes lookup)
// Returns array of keys to check: setting_id, slot-based, name-based
@@ -1042,9 +1045,9 @@ export function KProfilesView() {
refetchNotes();
} catch (err) {
console.error('Failed to save note:', err);
showToast('Failed to save note', 'error');
showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
}
}, [selectedPrinter, refetchNotes, showToast]);
}, [selectedPrinter, refetchNotes, showToast, t]);
// Get note for a profile (checks all possible keys)
// Returns { note, key } so we know which key the note was stored under
@@ -1077,9 +1080,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
<p className="text-bambu-gray">
Add a printer in Settings to manage K-profiles
{t('kProfiles.addPrinterInSettings')}
</p>
</CardContent>
</Card>
@@ -1091,9 +1094,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
<p className="text-bambu-gray">
Enable a printer connection to view its K-profiles
{t('kProfiles.enablePrinterConnection')}
</p>
</CardContent>
</Card>
@@ -1106,14 +1109,14 @@ export function KProfilesView() {
{isFetching && !kprofilesLoading && (
<div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
<Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">Loading K-Profiles...</p>
<p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
</div>
)}
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
<select
value={selectedPrinter || ''}
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
@@ -1128,7 +1131,7 @@ export function KProfilesView() {
</div>
<div className="w-32">
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
<select
value={nozzleDiameter}
onChange={(e) => setNozzleDiameter(e.target.value)}
@@ -1146,18 +1149,18 @@ export function KProfilesView() {
variant="secondary"
onClick={() => refetchProfiles()}
disabled={isFetching || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
{t('kProfiles.refresh')}
</Button>
<Button
onClick={() => setShowAddModal(true)}
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
>
<Plus className="w-4 h-4" />
Add Profile
{t('kProfiles.addProfile')}
</Button>
</div>
</div>
@@ -1170,7 +1173,7 @@ export function KProfilesView() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name or filament..."
placeholder={t('kProfiles.searchPlaceholder')}
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
@@ -1181,9 +1184,9 @@ export function KProfilesView() {
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
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">All Extruders</option>
<option value="left">Left Only</option>
<option value="right">Right Only</option>
<option value="all">{t('kProfiles.allExtruders')}</option>
<option value="left">{t('kProfiles.leftOnly')}</option>
<option value="right">{t('kProfiles.rightOnly')}</option>
</select>
</div>
)}
@@ -1193,9 +1196,9 @@ export function KProfilesView() {
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">All Flow</option>
<option value="hf">HF Only</option>
<option value="s">S Only</option>
<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">
@@ -1204,9 +1207,9 @@ export function KProfilesView() {
onChange={(e) => setSortOption(e.target.value as SortOption)}
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="name">Sort: Name</option>
<option value="k_value">Sort: K-Value</option>
<option value="filament">Sort: Filament</option>
<option value="name">{t('kProfiles.sortName')}</option>
<option value="k_value">{t('kProfiles.sortKValue')}</option>
<option value="filament">{t('kProfiles.sortFilament')}</option>
</select>
</div>
</div>
@@ -1217,19 +1220,19 @@ export function KProfilesView() {
variant="secondary"
onClick={handleExport}
disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
>
<Download className="w-4 h-4" />
Export
{t('kProfiles.export')}
</Button>
<Button
variant="secondary"
onClick={handleImport}
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
>
<Upload className="w-4 h-4" />
Import
{t('kProfiles.import')}
</Button>
<div className="flex-1" />
{selectionMode ? (
@@ -1237,20 +1240,19 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={selectAllProfiles}
title="Select all visible profiles"
>
<CheckSquare className="w-4 h-4" />
Select All
{t('kProfiles.selectAll')}
</Button>
<Button
variant="secondary"
onClick={handleBulkDelete}
disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
className="text-red-500 hover:bg-red-500/10"
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
>
<Trash2 className="w-4 h-4" />
Delete ({selectedProfiles.size})
{t('kProfiles.delete')} ({selectedProfiles.size})
</Button>
<Button
variant="secondary"
@@ -1260,7 +1262,7 @@ export function KProfilesView() {
}}
>
<X className="w-4 h-4" />
Cancel
{t('common.cancel')}
</Button>
</>
) : (
@@ -1268,10 +1270,10 @@ export function KProfilesView() {
variant="secondary"
onClick={() => setSelectionMode(true)}
disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
>
<CheckSquare className="w-4 h-4" />
Select
{t('kProfiles.select')}
</Button>
)}
</div>
@@ -1285,13 +1287,13 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
<p className="text-bambu-gray mb-4">
The selected printer is not connected. Power it on to view K-profiles.
{t('kProfiles.printerOfflineDesc')}
</p>
<Button variant="secondary" onClick={() => refetchProfiles()}>
<RefreshCw className="w-4 h-4" />
Retry
{t('common.refresh')}
</Button>
</CardContent>
</Card>
@@ -1301,7 +1303,7 @@ export function KProfilesView() {
<div className="grid grid-cols-2 gap-4">
{/* Left Extruder (extruder_id 1 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 1)
@@ -1321,7 +1323,7 @@ export function KProfilesView() {
</div>
{/* Right Extruder (extruder_id 0 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 0)
@@ -1361,9 +1363,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
<p className="text-bambu-gray">
No profiles match your search criteria
{t('kProfiles.noMatchingProfilesDesc')}
</p>
</CardContent>
</Card>
@@ -1371,13 +1373,13 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
<p className="text-bambu-gray mb-4">
No pressure advance profiles found for {nozzleDiameter}mm nozzle
{t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
</p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Create First Profile
{t('kProfiles.createFirstProfile')}
</Button>
</CardContent>
</Card>
@@ -1466,12 +1468,12 @@ export function KProfilesView() {
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
<h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
<p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
{t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
</p>
<div className="flex gap-3">
<Button
@@ -1480,7 +1482,7 @@ export function KProfilesView() {
disabled={bulkDeleteInProgress}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={executeBulkDelete}
@@ -1492,7 +1494,7 @@ export function KProfilesView() {
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
{t('common.delete')}
</Button>
</div>
</CardContent>
+20 -21
View File
@@ -619,7 +619,7 @@ export function Layout() {
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
@@ -723,7 +723,7 @@ export function Layout() {
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
@@ -824,20 +824,19 @@ export function Layout() {
</svg>
</div>
<h2 className="text-xl font-bold text-yellow-400 mb-2">
Print Paused!
{t('plateAlert.title')}
</h2>
<p className="text-lg text-white mb-2">
{plateDetectionAlert.printer_name}
</p>
<p className="text-bambu-gray mb-6">
Objects detected on build plate. The print has been automatically paused.
Please clear the plate and resume the print.
{t('plateAlert.message')}
</p>
<button
onClick={() => setPlateDetectionAlert(null)}
className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
>
I Understand
{t('plateAlert.understand')}
</button>
</div>
</div>
@@ -861,7 +860,7 @@ export function Layout() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Change Password</h2>
<h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
</div>
<Button
variant="ghost"
@@ -879,34 +878,34 @@ export function Layout() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Current Password
{t('changePassword.currentPassword')}
</label>
<input
type="password"
value={changePasswordData.currentPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter current password"
placeholder={t('changePassword.currentPasswordPlaceholder')}
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
New Password
{t('changePassword.newPassword')}
</label>
<input
type="password"
value={changePasswordData.newPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter new password (min 6 characters)"
placeholder={t('changePassword.newPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Confirm New Password
{t('changePassword.confirmPassword')}
</label>
<input
type="password"
@@ -917,12 +916,12 @@ export function Layout() {
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder="Confirm new password"
placeholder={t('changePassword.confirmPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
<p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
)}
</div>
</div>
@@ -934,26 +933,26 @@ export function Layout() {
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={async () => {
if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
showToast('Passwords do not match', 'error');
showToast(t('changePassword.passwordsDoNotMatch'), 'error');
return;
}
if (changePasswordData.newPassword.length < 6) {
showToast('Password must be at least 6 characters', 'error');
showToast(t('changePassword.passwordTooShort'), 'error');
return;
}
setChangePasswordLoading(true);
try {
await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
showToast('Password changed successfully', 'success');
showToast(t('changePassword.success'), 'success');
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to change password';
const message = error instanceof Error ? error.message : t('changePassword.failed');
showToast(message, 'error');
} finally {
setChangePasswordLoading(false);
@@ -964,12 +963,12 @@ export function Layout() {
{changePasswordLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Changing...
{t('changePassword.changing')}
</>
) : (
<>
<Key className="w-4 h-4" />
Change Password
{t('changePassword.title')}
</>
)}
</Button>
+16 -8
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Link2, Check } from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface LinkSpoolModalProps {
isOpen: boolean;
@@ -16,7 +18,9 @@ interface LinkSpoolModalProps {
}
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
// Fetch unlinked spools
@@ -31,9 +35,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
showToast(t('spoolman.linkSuccess'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
},
});
if (!isOpen) return null;
@@ -58,7 +67,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
<h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
</div>
<button
onClick={onClose}
@@ -89,14 +98,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{/* Spool UUID */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
</div>
{/* Spool list */}
<div>
<p className="text-sm text-bambu-gray mb-2">
Select a Spoolman spool to link:
{t('spoolman.selectSpool')}:
</p>
{isLoading ? (
@@ -141,8 +150,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>No unlinked spools found in Spoolman.</p>
<p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
<p>{t('spoolman.noUnlinkedSpools')}</p>
</div>
)}
</div>
@@ -151,7 +159,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleLink}
@@ -160,12 +168,12 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{linkMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Linking...
{t('spoolman.syncing')}
</>
) : (
<>
<Link2 className="w-4 h-4" />
Link Spool
{t('spoolman.linkToSpoolman')}
</>
)}
</Button>
+18 -16
View File
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
import { api, type MQTTLogEntry } from '../api/client';
import { Button } from './Button';
@@ -11,6 +12,7 @@ interface MQTTDebugModalProps {
}
export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
@@ -119,7 +121,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div>
<h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
<h2 className="text-lg font-semibold text-white">{t('mqttDebug.title')}</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
<button
@@ -141,7 +143,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={disableMutation.isPending}
>
<Square className="w-4 h-4" />
Stop
{t('mqttDebug.stopLogging')}
</Button>
) : (
<Button
@@ -150,7 +152,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={enableMutation.isPending}
>
<Play className="w-4 h-4" />
Start Logging
{t('mqttDebug.startLogging')}
</Button>
)}
<Button
@@ -160,7 +162,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={clearMutation.isPending || logs.length === 0}
>
<Trash2 className="w-4 h-4" />
Clear
{t('mqttDebug.clearLog')}
</Button>
<Button
size="sm"
@@ -191,7 +193,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Search topic or payload..."
placeholder={t('mqttDebug.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
@@ -214,7 +216,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
: 'text-bambu-gray hover:text-white'
}`}
>
All
{t('mqttDebug.all')}
</button>
<button
onClick={() => setDirectionFilter('in')}
@@ -225,7 +227,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
}`}
>
<ArrowDown className="w-3 h-3" />
In
{t('mqttDebug.incoming')}
</button>
<button
onClick={() => setDirectionFilter('out')}
@@ -236,7 +238,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
}`}
>
<ArrowUp className="w-3 h-3" />
Out
{t('mqttDebug.outgoing')}
</button>
</div>
</div>
@@ -249,15 +251,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
<p className="mb-2">No messages logged yet</p>
<p className="mb-2">{t('mqttDebug.noMessages')}</p>
{!loggingEnabled && (
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
<p className="text-sm">{t('mqttDebug.startLoggingHint')}</p>
)}
</div>
) : filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
<p className="mb-2">No messages match your filter</p>
<p className="text-sm">Try adjusting your search or filter criteria</p>
<p className="mb-2">{t('mqttDebug.noMessagesMatch')}</p>
<p className="text-sm">{t('mqttDebug.adjustFilterHint')}</p>
</div>
) : (
<div className="space-y-1">
@@ -281,7 +283,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
className={`shrink-0 ${
isIncoming ? 'text-blue-400' : 'text-green-400'
}`}
title={isIncoming ? 'Incoming' : 'Outgoing'}
title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}
>
{isIncoming ? (
<ArrowDown className="w-3 h-3" />
@@ -313,14 +315,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
{loggingEnabled ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logging active - messages will auto-refresh
{t('mqttDebug.loggingActive')}
</span>
) : (
<span>Logging stopped</span>
<span>{t('mqttDebug.loggingStopped')}</span>
)}
</div>
<Button variant="secondary" onClick={onClose}>
Close
{t('common.close')}
</Button>
</div>
</div>
@@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Selected target location (when assignmentMode is 'model') */
targetLocation?: string | null;
/** Handler for target location change */
onTargetLocationChange?: (location: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
@@ -227,6 +231,8 @@ export function PrinterSelector({
onAssignmentModeChange,
targetModel,
onTargetModelChange,
targetLocation,
onTargetLocationChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
@@ -257,6 +263,16 @@ export function PrinterSelector({
return [...new Set(models)].sort();
}, [activePrinters]);
// Get unique locations for the selected target model (for location filtering)
const uniqueLocations = useMemo(() => {
if (!targetModel) return [];
const locations = activePrinters
.filter(p => p.model === targetModel && p.location)
.map(p => p.location)
.filter((l): l is string => Boolean(l));
return [...new Set(locations)].sort();
}, [activePrinters, targetModel]);
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
@@ -370,11 +386,59 @@ export function PrinterSelector({
</div>
)}
{/* Model info (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
<p className="text-xs text-bambu-gray mb-4">
Scheduler will assign to first available idle {targetModel} printer
</p>
{/* Model selection and location filter (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && (
<div className="space-y-3 mb-4">
{/* Model selector */}
<div>
<label className="block text-xs text-bambu-gray mb-1">Target Model</label>
<select
value={targetModel || ''}
onChange={(e) => {
onTargetModelChange!(e.target.value || null);
// Clear location when model changes
if (onTargetLocationChange) {
onTargetLocationChange(null);
}
}}
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 text-sm"
>
<option value="">Select a model...</option>
{uniqueModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
{/* Location filter (only show when target model is selected and locations exist) */}
{targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
<select
value={targetLocation || ''}
onChange={(e) => onTargetLocationChange(e.target.value || null)}
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 text-sm"
>
<option value="">Any location</option>
{uniqueLocations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
)}
{/* Info text */}
{targetModel && (
<p className="text-xs text-bambu-gray">
Scheduler will assign to first available idle {targetModel} printer
{targetLocation ? ` in ${targetLocation}` : ''}
</p>
)}
</div>
)}
{/* Multi-select header (only in printer mode) */}
@@ -1,17 +1,117 @@
import { useState, useEffect, useRef } from 'react';
import { Calendar, Clock, Hand, Power } from 'lucide-react';
import { getMinDateTime } from '../../utils/amsHelpers';
import type { ScheduleOptionsProps, ScheduleType } from './types';
import {
formatDateInput,
formatTimeInput,
parseDateInput,
parseTimeInput,
getDatePlaceholder,
getTimePlaceholder,
toDateTimeLocalValue,
type DateFormat,
type TimeFormat,
} from '../../utils/date';
/**
* Schedule options component for queue items.
* Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
* and options for require previous success and auto power off.
*/
export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) {
export function ScheduleOptionsPanel({
options,
onChange,
dateFormat = 'system',
timeFormat = 'system',
}: ScheduleOptionsProps) {
const [dateValue, setDateValue] = useState('');
const [timeValue, setTimeValue] = useState('');
const [isDateValid, setIsDateValid] = useState(true);
const [isTimeValid, setIsTimeValid] = useState(true);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const isInitializedRef = useRef(false);
// Initialize or sync from options.scheduledTime
useEffect(() => {
if (options.scheduleType !== 'scheduled') {
isInitializedRef.current = false;
return;
}
// Initialize with default time (now + 1 hour) or from existing value
if (!isInitializedRef.current) {
isInitializedRef.current = true;
let date: Date;
if (options.scheduledTime) {
date = new Date(options.scheduledTime);
if (isNaN(date.getTime())) {
date = new Date();
date.setHours(date.getHours() + 1, 0, 0, 0);
}
} else {
date = new Date();
date.setHours(date.getHours() + 1, 0, 0, 0);
// Set initial value
onChange({ ...options, scheduledTime: toDateTimeLocalValue(date) });
}
setDateValue(formatDateInput(date, dateFormat as DateFormat));
setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
setIsDateValid(true);
setIsTimeValid(true);
}
}, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);
const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
onChange({ ...options, scheduleType });
};
const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {
const parsedDate = parseDateInput(newDateValue, dateFormat as DateFormat);
const parsedTime = parseTimeInput(newTimeValue);
setIsDateValid(!!parsedDate);
setIsTimeValid(!!parsedTime);
if (parsedDate && parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0);
const now = new Date();
if (parsedDate > now) {
onChange({ ...options, scheduledTime: toDateTimeLocalValue(parsedDate) });
}
}
};
const handleDateChange = (value: string) => {
setDateValue(value);
updateScheduledTime(value, timeValue);
};
const handleTimeChange = (value: string) => {
setTimeValue(value);
updateScheduledTime(dateValue, value);
};
// Handle calendar picker selection
const handleCalendarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
setDateValue(formatDateInput(date, dateFormat as DateFormat));
setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
setIsDateValid(true);
setIsTimeValid(true);
onChange({ ...options, scheduledTime: value });
}
}
};
const openCalendar = () => {
hiddenInputRef.current?.showPicker();
};
return (
<div className="space-y-4">
{/* Schedule type */}
@@ -61,14 +161,58 @@ export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps
{options.scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<div className="flex gap-2">
{/* Date input */}
<div className="flex-1 relative">
<input
type="text"
className={`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
isDateValid
? 'border-bambu-dark-tertiary focus:border-bambu-green'
: 'border-red-500'
}`}
value={dateValue}
onChange={(e) => handleDateChange(e.target.value)}
placeholder={getDatePlaceholder(dateFormat as DateFormat)}
/>
<button
type="button"
onClick={openCalendar}
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
title="Open calendar"
>
<Calendar className="w-4 h-4" />
</button>
</div>
{/* Time input */}
<div className="w-32">
<input
type="text"
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
isTimeValid
? 'border-bambu-dark-tertiary focus:border-bambu-green'
: 'border-red-500'
}`}
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
placeholder={getTimePlaceholder(timeFormat as TimeFormat)}
/>
</div>
</div>
{/* Hidden datetime-local for calendar picker */}
<input
ref={hiddenInputRef}
type="datetime-local"
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"
className="sr-only"
value={options.scheduledTime}
onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
min={getMinDateTime()}
required
onChange={handleCalendarChange}
tabIndex={-1}
/>
{(!isDateValid || !isTimeValid) && (
<p className="mt-1 text-xs text-red-400">
Please enter a valid date and time
</p>
)}
</div>
)}
+33 -13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
@@ -41,6 +42,7 @@ export function PrintModal({
onClose,
onSuccess,
}: PrintModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -135,6 +137,14 @@ export function PrintModal({
return null;
});
// Target location for model-based assignment (optional filter)
const [targetLocation, setTargetLocation] = useState<string | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.target_location) {
return queueItem.target_location;
}
return null;
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -369,6 +379,7 @@ export function PrintModal({
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
target_location: assignmentMode === 'model' ? targetLocation : null,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -397,6 +408,7 @@ export function PrintModal({
const updateData: PrintQueueItemUpdate = {
printer_id: null,
target_model: targetModel,
target_location: targetLocation,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
@@ -445,6 +457,7 @@ export function PrintModal({
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
target_model: null,
target_location: null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
@@ -518,35 +531,35 @@ export function PrintModal({
if (mode === 'reprint') {
return {
title: isLibraryFile ? 'Print' : 'Re-print',
title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
icon: Printer,
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
submitIcon: Printer,
loadingText: submitProgress.total > 1
? `Sending ${submitProgress.current}/${submitProgress.total}...`
: 'Sending...',
? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.sending'),
};
}
if (mode === 'add-to-queue') {
return {
title: 'Schedule Print',
title: t('queue.schedulePrint'),
icon: Calendar,
submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
submitIcon: Calendar,
loadingText: submitProgress.total > 1
? `Adding ${submitProgress.current}/${submitProgress.total}...`
: 'Adding...',
? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.adding'),
};
}
// edit-queue-item mode
return {
title: 'Edit Queue Item',
title: t('queue.editQueueItem'),
icon: Pencil,
submitText: 'Save',
submitText: t('common.save'),
submitIcon: Pencil,
loadingText: submitProgress.total > 1
? `Saving ${submitProgress.current}/${submitProgress.total}...`
: 'Saving...',
? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('common.saving'),
};
};
@@ -626,6 +639,8 @@ export function PrintModal({
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
targetLocation={targetLocation}
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
slicedForModel={slicedForModel}
/>
@@ -673,7 +688,12 @@ export function PrintModal({
{/* Schedule options - only for queue modes */}
{mode !== 'reprint' && (
<ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
<ScheduleOptionsPanel
options={scheduleOptions}
onChange={setScheduleOptions}
dateFormat={settings?.date_format || 'system'}
timeFormat={settings?.time_format || 'system'}
/>
)}
{/* Error message */}
@@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Selected target location (when assignmentMode is 'model') */
targetLocation?: string | null;
/** Handler for target location change */
onTargetLocationChange?: (location: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
@@ -183,4 +187,8 @@ export interface PrintOptionsProps {
export interface ScheduleOptionsProps {
options: ScheduleOptions;
onChange: (options: ScheduleOptions) => void;
/** Date format setting from user preferences */
dateFormat?: 'system' | 'us' | 'eu' | 'iso';
/** Time format setting from user preferences */
timeFormat?: 'system' | '12h' | '24h';
}
@@ -79,6 +79,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
// Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
if (plug.printer_id) {
queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
}
},
});
@@ -88,6 +89,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
mutationFn: () => api.deleteSmartPlug(plug.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
// Also invalidate printer card HA entity queries
if (plug.printer_id) {
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
}
},
});
+2 -28
View File
@@ -6,32 +6,6 @@ import type { SpoolmanSyncResult, Printer } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
interface SpoolmanSettingsData {
spoolman_enabled: string;
spoolman_url: string;
spoolman_sync_mode: string;
}
async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
const response = await fetch('/api/v1/settings/spoolman');
if (!response.ok) {
throw new Error('Failed to load Spoolman settings');
}
return response.json();
}
async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
const response = await fetch('/api/v1/settings/spoolman', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to save Spoolman settings');
}
return response.json();
}
export function SpoolmanSettings() {
const queryClient = useQueryClient();
const [localEnabled, setLocalEnabled] = useState(false);
@@ -45,7 +19,7 @@ export function SpoolmanSettings() {
// Fetch Spoolman settings
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: getSpoolmanSettings,
queryFn: api.getSpoolmanSettings,
});
// Fetch Spoolman status
@@ -93,7 +67,7 @@ export function SpoolmanSettings() {
// Save mutation
const saveMutation = useMutation({
mutationFn: () =>
updateSpoolmanSettings({
api.updateSpoolmanSettings({
spoolman_enabled: localEnabled ? 'true' : 'false',
spoolman_url: localUrl,
spoolman_sync_mode: localSyncMode,
+12 -10
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
@@ -20,6 +21,7 @@ interface UploadModalProps {
}
export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -150,7 +152,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
<CardContent className="p-0 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
<h2 className="text-xl font-semibold text-white">{t('uploadModal.title')}</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
@@ -173,15 +175,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
>
<Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
<p className="text-white mb-2">
Drag & drop .3mf files here
{t('uploadModal.dragDrop')}
</p>
<p className="text-bambu-gray text-sm mb-4">or</p>
<p className="text-bambu-gray text-sm mb-4">{t('uploadModal.or')}</p>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
Browse Files
{t('uploadModal.browseFiles')}
</Button>
<input
ref={fileInputRef}
@@ -197,7 +199,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{/* Info about printer model extraction */}
<div className="px-4 pb-4">
<p className="text-xs text-bambu-gray">
The printer model will be automatically extracted from the 3MF file metadata.
{t('uploadModal.extractionInfo')}
</p>
</div>
@@ -249,9 +251,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
<div className="px-4 pb-4">
<div className="p-3 bg-bambu-dark rounded-lg">
<p className="text-sm text-white">
<span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
<span className="text-bambu-green">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}
{uploadResult.failed > 0 && (
<>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
<>, <span className="text-red-400">{uploadResult.failed}</span> {t('uploadModal.failed')}</>
)}
</p>
</div>
@@ -261,7 +263,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
{uploadResult ? 'Close' : 'Cancel'}
{uploadResult ? t('common.close') : t('common.cancel')}
</Button>
{!uploadResult && (
<Button
@@ -272,12 +274,12 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
{t('uploadModal.uploading')}
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount > 0 && `(${pendingCount})`}
{t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}
</>
)}
</Button>
+268 -117
View File
@@ -1,21 +1,26 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink } from 'lucide-react';
import { virtualPrinterApi } from '../api/client';
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink, ArrowRightLeft } from 'lucide-react';
import { api, virtualPrinterApi } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
export function VirtualPrinterSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [localEnabled, setLocalEnabled] = useState(false);
const [localAccessCode, setLocalAccessCode] = useState('');
const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
const [localMode, setLocalMode] = useState<LocalMode>('immediate');
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
const [showAccessCode, setShowAccessCode] = useState(false);
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
// Fetch current settings
const { data: settings, isLoading } = useQuery({
@@ -30,38 +35,46 @@ export function VirtualPrinterSettings() {
queryFn: virtualPrinterApi.getModels,
});
// Fetch printers for proxy mode dropdown
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Initialize local state from settings
useEffect(() => {
if (settings) {
setLocalEnabled(settings.enabled);
// Map legacy 'queue' mode to 'review'
let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
let mode: LocalMode = settings.mode === 'queue' ? 'review' : settings.mode as LocalMode;
if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue' && mode !== 'proxy') {
mode = 'immediate'; // fallback
}
setLocalMode(mode);
setLocalModel(settings.model);
setLocalTargetPrinterId(settings.target_printer_id);
}
}, [settings]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
virtualPrinterApi.updateSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
showToast('Virtual printer settings updated');
showToast(t('virtualPrinter.toast.updated'));
setPendingAction(null);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to update settings', 'error');
showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
// Revert local state on error
if (settings) {
setLocalEnabled(settings.enabled);
// Map legacy 'queue' mode to 'review'
const mode = settings.mode === 'queue' ? 'review' : settings.mode;
setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
setLocalMode(['immediate', 'review', 'print_queue', 'proxy'].includes(mode) ? mode as LocalMode : 'immediate');
setLocalModel(settings.model);
setLocalTargetPrinterId(settings.target_printer_id);
}
setPendingAction(null);
},
@@ -70,29 +83,41 @@ export function VirtualPrinterSettings() {
const handleToggleEnabled = () => {
const newEnabled = !localEnabled;
// If enabling, must have access code
if (newEnabled && !localAccessCode && !settings?.access_code_set) {
showToast('Please set an access code first', 'error');
return;
// Validation depends on mode
if (newEnabled) {
if (localMode === 'proxy') {
// Proxy mode requires target printer
if (!localTargetPrinterId) {
showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
return;
}
} else {
// Other modes require access code
if (!localAccessCode && !settings?.access_code_set) {
showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
return;
}
}
}
setLocalEnabled(newEnabled);
setPendingAction('toggle');
updateMutation.mutate({
enabled: newEnabled,
access_code: localAccessCode || undefined,
access_code: localMode !== 'proxy' ? (localAccessCode || undefined) : undefined,
mode: localMode,
target_printer_id: localMode === 'proxy' ? (localTargetPrinterId ?? undefined) : undefined,
});
};
const handleAccessCodeChange = () => {
if (!localAccessCode) {
showToast('Access code cannot be empty', 'error');
showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
return;
}
if (localAccessCode.length !== 8) {
showToast('Access code must be exactly 8 characters', 'error');
showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
return;
}
@@ -103,12 +128,20 @@ export function VirtualPrinterSettings() {
setLocalAccessCode(''); // Clear after saving
};
const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
const handleModeChange = (mode: LocalMode) => {
setLocalMode(mode);
setPendingAction('mode');
updateMutation.mutate({ mode });
};
const handleTargetPrinterChange = (printerId: number) => {
setLocalTargetPrinterId(printerId);
setPendingAction('targetPrinter');
updateMutation.mutate({
target_printer_id: printerId,
});
};
const handleModelChange = (model: string) => {
setLocalModel(model);
setPendingAction('model');
@@ -137,28 +170,33 @@ export function VirtualPrinterSettings() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Printer className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Virtual Printer</h2>
<h2 className="text-lg font-semibold text-white">{t('virtualPrinter.title')}</h2>
</div>
{status && (
<div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
<span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
{isRunning ? 'Running' : 'Stopped'}
{isRunning ? t('virtualPrinter.running') : t('virtualPrinter.stopped')}
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
will be archived directly without printing.
{localMode === 'proxy'
? t('virtualPrinter.description.proxy')
: t('virtualPrinter.description.default')}
</p>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
<div>
<div className="text-white font-medium">Enable Virtual Printer</div>
<div className="text-white font-medium">{t('virtualPrinter.enable.title')}</div>
<div className="text-sm text-bambu-gray">
{isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
{isRunning ? (
localMode === 'proxy'
? t('virtualPrinter.enable.proxyingTo', { name: printers?.find(p => p.id === localTargetPrinterId)?.name || 'printer' })
: t('virtualPrinter.enable.visibleInSlicer')
) : t('virtualPrinter.enable.notActive')}
</div>
</div>
<button
@@ -176,11 +214,12 @@ export function VirtualPrinterSettings() {
</button>
</div>
{/* Printer Model */}
{/* Printer Model - only for non-proxy modes */}
{localMode !== 'proxy' && (
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Printer Model</div>
<div className="text-white font-medium mb-2">{t('virtualPrinter.model.title')}</div>
<div className="text-sm text-bambu-gray mb-3">
Select which printer model to emulate.
{t('virtualPrinter.model.description')}
</div>
<div className="relative">
<select
@@ -202,66 +241,119 @@ export function VirtualPrinterSettings() {
{localEnabled && isRunning && (
<p className="text-xs text-bambu-gray mt-2">
<Info className="w-3 h-3 inline mr-1" />
Changing the model will restart the virtual printer
{t('virtualPrinter.model.restartWarning')}
</p>
)}
</div>
)}
{/* Access Code */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Access Code</div>
<div className="text-sm text-bambu-gray mb-3">
{settings?.access_code_set ? (
<span className="flex items-center gap-1 text-green-400">
<Check className="w-4 h-4" />
Access code is set
</span>
) : (
<span className="flex items-center gap-1 text-yellow-400">
<AlertTriangle className="w-4 h-4" />
No access code set - required to enable
</span>
)}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showAccessCode ? 'text' : 'password'}
value={localAccessCode}
onChange={(e) => setLocalAccessCode(e.target.value)}
placeholder={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
maxLength={8}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
/>
<button
onClick={() => setShowAccessCode(!showAccessCode)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
>
{showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{/* Access Code - only for non-proxy modes */}
{localMode !== 'proxy' && (
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">{t('virtualPrinter.accessCode.title')}</div>
<div className="text-sm text-bambu-gray mb-3">
{settings?.access_code_set ? (
<span className="flex items-center gap-1 text-green-400">
<Check className="w-4 h-4" />
{t('virtualPrinter.accessCode.isSet')}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-400">
<AlertTriangle className="w-4 h-4" />
{t('virtualPrinter.accessCode.notSet')}
</span>
)}
</div>
<Button
onClick={handleAccessCodeChange}
disabled={!localAccessCode || pendingAction === 'accessCode'}
variant="primary"
>
{pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
</Button>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showAccessCode ? 'text' : 'password'}
value={localAccessCode}
onChange={(e) => setLocalAccessCode(e.target.value)}
placeholder={settings?.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
maxLength={8}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
/>
<button
onClick={() => setShowAccessCode(!showAccessCode)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
>
{showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button
onClick={handleAccessCodeChange}
disabled={!localAccessCode || pendingAction === 'accessCode'}
variant="primary"
>
{pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
</Button>
</div>
<p className="text-xs text-bambu-gray mt-2">
{t('virtualPrinter.accessCode.hint')}
{localAccessCode && (
<span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
{' '}{t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
</span>
)}
</p>
</div>
<p className="text-xs text-bambu-gray mt-2">
Must be exactly 8 characters. Used by slicers to authenticate.
{localAccessCode && (
<span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
{' '}({localAccessCode.length}/8)
</span>
)}
{/* Target Printer - only for proxy mode */}
{localMode === 'proxy' && (
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</div>
<div className="text-sm text-bambu-gray mb-3">
{localTargetPrinterId ? (
<span className="flex items-center gap-1 text-green-400">
<Check className="w-4 h-4" />
{t('virtualPrinter.targetPrinter.configured')}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-400">
<AlertTriangle className="w-4 h-4" />
{t('virtualPrinter.targetPrinter.notConfigured')}
</span>
)}
</div>
<div className="relative">
<select
value={localTargetPrinterId ?? ''}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
if (!isNaN(id)) {
handleTargetPrinterChange(id);
}
}}
disabled={pendingAction === 'targetPrinter'}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
>
<option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name} ({printer.ip_address})
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-2">
{t('virtualPrinter.targetPrinter.hint')}
</p>
{!printers?.length && (
<p className="text-xs text-yellow-400 mt-2">
<AlertTriangle className="w-3 h-3 inline mr-1" />
{t('virtualPrinter.targetPrinter.noPrinters')}
</p>
)}
</p>
</div>
</div>
)}
{/* Mode */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Mode</div>
<div className="grid grid-cols-3 gap-3">
<div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleModeChange('immediate')}
disabled={pendingAction === 'mode'}
@@ -271,8 +363,8 @@ export function VirtualPrinterSettings() {
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Archive</div>
<div className="text-xs text-bambu-gray">Archive files immediately</div>
<div className="text-white font-medium">{t('virtualPrinter.mode.archive')}</div>
<div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.archiveDesc')}</div>
</button>
<button
onClick={() => handleModeChange('review')}
@@ -283,8 +375,8 @@ export function VirtualPrinterSettings() {
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Review</div>
<div className="text-xs text-bambu-gray">Review and tag before archiving</div>
<div className="text-white font-medium">{t('virtualPrinter.mode.review')}</div>
<div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.reviewDesc')}</div>
</button>
<button
onClick={() => handleModeChange('print_queue')}
@@ -295,8 +387,23 @@ export function VirtualPrinterSettings() {
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Queue</div>
<div className="text-xs text-bambu-gray">Archive and add to print queue</div>
<div className="text-white font-medium">{t('virtualPrinter.mode.queue')}</div>
<div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.queueDesc')}</div>
</button>
<button
onClick={() => handleModeChange('proxy')}
disabled={pendingAction === 'mode'}
className={`p-3 rounded-lg border text-left transition-colors ${
localMode === 'proxy'
? 'border-blue-500 bg-blue-500/10'
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center gap-1.5 text-white font-medium">
<ArrowRightLeft className="w-4 h-4" />
{t('virtualPrinter.mode.proxy')}
</div>
<div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.proxyDesc')}</div>
</button>
</div>
</div>
@@ -313,11 +420,10 @@ export function VirtualPrinterSettings() {
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-white font-medium mb-2">
Setup Required
{t('virtualPrinter.setupRequired.title')}
</p>
<p className="text-bambu-gray mb-3">
The virtual printer feature requires additional system configuration before it will work.
This includes port forwarding, firewall rules, and platform-specific settings.
{t('virtualPrinter.setupRequired.description')}
</p>
<a
href="https://wiki.bambuddy.cool/features/virtual-printer/"
@@ -326,7 +432,7 @@ export function VirtualPrinterSettings() {
className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Read the setup guide before enabling
{t('virtualPrinter.setupRequired.readGuide')}
</a>
</div>
</div>
@@ -340,16 +446,26 @@ export function VirtualPrinterSettings() {
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-bambu-gray">
<p className="mb-2">
<strong className="text-white">How it works:</strong>
<strong className="text-white">{localMode === 'proxy' ? t('virtualPrinter.howItWorks.titleProxy') : t('virtualPrinter.howItWorks.title')}:</strong>
</p>
<ol className="list-decimal list-inside space-y-1">
<li>Complete the setup guide for your platform</li>
<li>Enable the virtual printer and set an access code</li>
<li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
<li>The "Bambuddy" printer should appear in the discovery list</li>
<li>Connect using the access code you set</li>
<li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
</ol>
{localMode === 'proxy' ? (
<ol className="list-decimal list-inside space-y-1">
<li>{t('virtualPrinter.howItWorks.proxyStep1')}</li>
<li>{t('virtualPrinter.howItWorks.proxyStep2')}</li>
<li>{t('virtualPrinter.howItWorks.proxyStep3')}</li>
<li>{t('virtualPrinter.howItWorks.proxyStep4')}</li>
<li>{t('virtualPrinter.howItWorks.proxyStep5')}</li>
</ol>
) : (
<ol className="list-decimal list-inside space-y-1">
<li>{t('virtualPrinter.howItWorks.step1')}</li>
<li>{t('virtualPrinter.howItWorks.step2')}</li>
<li>{t('virtualPrinter.howItWorks.step3')}</li>
<li>{t('virtualPrinter.howItWorks.step4')}</li>
<li>{t('virtualPrinter.howItWorks.step5')}</li>
<li>{t('virtualPrinter.howItWorks.step6')}</li>
</ol>
)}
</div>
</div>
</CardContent>
@@ -359,31 +475,66 @@ export function VirtualPrinterSettings() {
{status && isRunning && (
<Card>
<CardHeader>
<h3 className="text-md font-semibold text-white">Status Details</h3>
<h3 className="text-md font-semibold text-white">{t('virtualPrinter.status.title')}</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-bambu-gray">Printer Name</div>
<div className="text-white">{status.name}</div>
{status.mode === 'proxy' && status.proxy ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.targetPrinter')}</div>
<div className="text-white">
{printers?.find(p => p.id === localTargetPrinterId)?.name || status.proxy.target_host}
</div>
<div className="text-xs text-bambu-gray font-mono">{status.proxy.target_host}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
<div className="text-white flex items-center gap-1.5">
<ArrowRightLeft className="w-4 h-4" />
{t('virtualPrinter.mode.proxy')}
</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.ftpPort')}</div>
<div className="text-white font-mono">{status.proxy.ftp_port}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.mqttPort')}</div>
<div className="text-white font-mono">{status.proxy.mqtt_port}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
<div className="text-white">{status.proxy.ftp_connections}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
<div className="text-white">{status.proxy.mqtt_connections}</div>
</div>
</div>
<div>
<div className="text-bambu-gray">Model</div>
<div className="text-white">{status.model_name || status.model}</div>
) : (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.printerName')}</div>
<div className="text-white">{status.name}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.model')}</div>
<div className="text-white">{status.model_name || status.model}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.serialNumber')}</div>
<div className="text-white font-mono">{status.serial}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
<div className="text-white capitalize">{status.mode}</div>
</div>
<div>
<div className="text-bambu-gray">{t('virtualPrinter.status.pendingFiles')}</div>
<div className="text-white">{status.pending_files}</div>
</div>
</div>
<div>
<div className="text-bambu-gray">Serial Number</div>
<div className="text-white font-mono">{status.serial}</div>
</div>
<div>
<div className="text-bambu-gray">Mode</div>
<div className="text-white capitalize">{status.mode}</div>
</div>
<div>
<div className="text-bambu-gray">Pending Files</div>
<div className="text-white">{status.pending_files}</div>
</div>
</div>
)}
</CardContent>
</Card>
)}