mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Merge branch '0.1.8b' into feature/updated_plate_view
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user