This commit is contained in:
maziggy
2025-12-08 09:56:21 +00:00
parent 71e15b0ed7
commit e47ef36630
27 changed files with 12163 additions and 9180 deletions
+76 -1
View File
@@ -283,6 +283,39 @@ export interface SlicerSettingsResponse {
process: SlicerSetting[];
}
export interface SlicerSettingDetail {
message?: string | null;
code?: string | null;
error?: string | null;
public: boolean;
version?: string | null;
type: string;
name: string;
update_time?: string | null;
nickname?: string | null;
base_id?: string | null;
setting: Record<string, unknown>;
filament_id?: string | null;
setting_id?: string | null;
}
export interface SlicerSettingCreate {
type: string; // 'filament', 'print', or 'printer'
name: string;
base_id: string;
setting: Record<string, unknown>;
}
export interface SlicerSettingUpdate {
name?: string;
setting?: Record<string, unknown>;
}
export interface SlicerSettingDeleteResponse {
success: boolean;
message: string;
}
export interface CloudDevice {
dev_id: string;
name: string;
@@ -382,6 +415,7 @@ export interface PrintQueueItem {
archive_name?: string | null;
archive_thumbnail?: string | null;
printer_name?: string | null;
print_time_seconds?: number | null; // Estimated print time from archive
}
export interface PrintQueueItemCreate {
@@ -456,6 +490,15 @@ export interface KProfilesResponse {
nozzle_diameter: string;
}
export interface KProfileNote {
setting_id: string;
note: string;
}
export interface KProfileNotesResponse {
notes: Record<string, string>; // setting_id -> note
}
// Slot Preset Mapping
export interface SlotPresetMapping {
ams_id: number;
@@ -1004,7 +1047,21 @@ export const api = {
getCloudSettings: (version = '01.09.00.00') =>
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getCloudSettingDetail: (settingId: string) =>
request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
createCloudSetting: (data: SlicerSettingCreate) =>
request<SlicerSettingDetail>('/cloud/settings', {
method: 'POST',
body: JSON.stringify(data),
}),
updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteCloudSetting: (settingId: string) =>
request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {
method: 'DELETE',
}),
getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
// Smart Plugs
@@ -1079,6 +1136,24 @@ export const api = {
method: 'DELETE',
body: JSON.stringify(profile),
}),
setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
method: 'POST',
body: JSON.stringify(profiles),
}),
// K-Profile Notes (stored locally, not on printer)
getKProfileNotes: (printerId: number) =>
request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
setKProfileNote: (printerId: number, settingId: string, note: string) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {
method: 'PUT',
body: JSON.stringify({ setting_id: settingId, note }),
}),
deleteKProfileNote: (printerId: number, settingId: string) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {
method: 'DELETE',
}),
// Slot Preset Mappings
getSlotPresets: (printerId: number) =>
+15 -8
View File
@@ -31,8 +31,11 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
const batchTagMutation = useMutation({
mutationFn: async () => {
const tagsArray = Array.from(selectedTags);
await Promise.all(
selectedIds.map(async (id) => {
let successCount = 0;
// Process sequentially to avoid SQLite database locks
for (const id of selectedIds) {
try {
const archive = await api.getArchive(id);
const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -45,18 +48,22 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
newTags = currentTags.filter(t => !selectedTags.has(t));
}
return api.updateArchive(id, { tags: newTags.join(', ') });
})
);
return { count: selectedIds.length, mode, tags: tagsArray };
await api.updateArchive(id, { tags: newTags.join(', ') });
successCount++;
} catch (err) {
console.error(`Failed to update archive ${id}:`, err);
throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return { count: successCount, mode, tags: tagsArray };
},
onSuccess: ({ count, mode, tags }) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
onClose();
},
onError: () => {
showToast('Failed to update tags', 'error');
onError: (error: Error) => {
showToast(error.message || 'Failed to update tags', 'error');
},
});
@@ -0,0 +1,243 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface EditQueueItemModalProps {
item: PrintQueueItem;
onClose: () => void;
}
export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number>(item.printer_id);
// Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
const isPlaceholderDate = item.scheduled_time &&
new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>(
item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap'
);
const [scheduledTime, setScheduledTime] = useState(() => {
if (item.scheduled_time && !isPlaceholderDate) {
// Convert ISO to local datetime-local format
const date = new Date(item.scheduled_time);
return date.toISOString().slice(0, 16);
}
return '';
});
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const updateMutation = useMutation({
mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item updated');
onClose();
},
onError: (error: Error) => {
showToast(error.message || 'Failed to update queue item', 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: PrintQueueItemUpdate = {
printer_id: printerId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
};
if (scheduleType === 'scheduled' && scheduledTime) {
data.scheduled_time = new Date(scheduledTime).toISOString();
} else {
data.scheduled_time = null;
}
updateMutation.mutate(data);
};
// Get minimum datetime (now + 1 minute)
const getMinDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Archive name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Print Job</label>
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
</p>
</div>
{/* Printer selection */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
{printers?.length === 0 ? (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
No printers configured
</div>
) : (
<select
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"
value={printerId}
onChange={(e) => setPrinterId(Number(e.target.value))}
required
>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP (when idle)
</button>
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
</div>
</div>
{/* Scheduled time input */}
{scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<input
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"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
min={getMinDateTime()}
required
/>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requirePrevious"
checked={requirePreviousSuccess}
onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
</label>
</div>
{/* Auto power off */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoOffAfter"
checked={autoOffAfter}
onChange={(e) => setAutoOffAfter(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
<Power className="w-3.5 h-3.5" />
Power off printer when done
</label>
</div>
{/* Help text */}
<p className="text-xs text-bambu-gray">
{scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
</p>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={updateMutation.isPending || printers?.length === 0}
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+651 -91
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Gauge,
Loader2,
@@ -11,6 +11,12 @@ import {
WifiOff,
Trash2,
Search,
Copy,
Download,
Upload,
CheckSquare,
Square,
StickyNote,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
@@ -21,6 +27,11 @@ import { useToast } from '../contexts/ToastContext';
interface KProfileCardProps {
profile: KProfile;
onEdit: () => void;
onCopy?: () => void;
selectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
note?: string; // Note text to display as preview
}
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
@@ -64,33 +75,71 @@ const extractFilamentName = (profileName: string) => {
return profileName;
};
function KProfileCard({ profile, onEdit }: KProfileCardProps) {
function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
const flowType = getFlowTypeLabel(profile.nozzle_id);
const diameter = profile.nozzle_diameter;
const profileName = profile.name || 'Unnamed';
// Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
const filamentName = extractFilamentName(profile.name || '');
const handleClick = () => {
if (selectionMode && onToggleSelect) {
onToggleSelect();
} else {
onEdit();
}
};
return (
<button
onClick={onEdit}
className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profileName}>
{profileName}
</span>
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
<div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
Filament: {filamentName || profile.filament_id}
</div>
</button>
<div className="flex items-center gap-2">
{selectionMode && (
<button
onClick={onToggleSelect}
className="text-bambu-gray hover:text-white transition-colors p-1"
>
{isSelected ? (
<CheckSquare className="w-4 h-4 text-bambu-green" />
) : (
<Square className="w-4 h-4" />
)}
</button>
)}
<button
onClick={handleClick}
className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profile.name}>
{profile.name || 'Unnamed'}
</span>
{note && (
<span title="Has note">
<StickyNote className="w-3 h-3 text-yellow-500" />
</span>
)}
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
{note && (
<div className="text-xs mt-0.5 truncate text-yellow-500/70" title={note}>
Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}
</div>
)}
</button>
{!selectionMode && onCopy && (
<button
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
className="text-bambu-gray hover:text-white transition-colors p-1"
title="Copy profile"
>
<Copy className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -100,8 +149,11 @@ interface KProfileModalProps {
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
initialNote?: string; // Initial note value for the profile
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
onClose: () => void;
onSave: () => void;
onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
}
function KProfileModal({
@@ -110,11 +162,13 @@ function KProfileModal({
nozzleDiameter,
existingProfiles = [],
isDualNozzle = false,
initialNote = '',
initialNoteKey = null,
onClose,
onSave,
onSaveNote,
}: KProfileModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
@@ -135,6 +189,7 @@ function KProfileModal({
);
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
const [note, setNote] = useState(initialNote);
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
@@ -162,12 +217,26 @@ function KProfileModal({
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
// Save note if it changed (including clearing it)
if (onSaveNote && note !== initialNote) {
let profileKey: string;
if (note === '' && initialNoteKey) {
// Clearing note: use the same key it was stored under
profileKey = initialNoteKey;
} else if (profile && profile.slot_id > 0) {
// Editing: use setting_id if available, or composite key with slot_id
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
} else {
// New profile: use name as key (will be matched when profile is loaded)
profileKey = `name_${name}_${filamentId}`;
}
onSaveNote(profileKey, note);
}
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the save
// Bambu printers can be slow to apply K-profile changes
// Add delay before closing to give printer time to process the save
// onSave will trigger refetch in the parent component
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onSave();
}, 2500);
@@ -189,13 +258,12 @@ function KProfileModal({
showToast('K-profile deleted');
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the delete
// Bambu printers can be slow to apply K-profile changes
// Add longer delay for delete - printer needs more time to process
// before it can return the updated profile list
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onClose();
}, 2500);
}, 4000);
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
@@ -249,49 +317,48 @@ function KProfileModal({
return;
}
// For new profiles with multiple extruders: save sequentially
// For new profiles with multiple extruders: use batch endpoint
setIsSyncing(true);
setSavingProgress({ current: 0, total: selectedExtruders.length });
setSavingProgress({ current: 1, total: selectedExtruders.length });
for (let i = 0; i < selectedExtruders.length; i++) {
const extruderId = selectedExtruders[i];
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
};
// Build payload for all selected extruders
const batchPayload = selectedExtruders.map(extruderId => ({
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
}));
setSavingProgress({ current: i + 1, total: selectedExtruders.length });
console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
try {
await api.setKProfile(printerId, payload);
// Wait between saves to let printer process
if (i < selectedExtruders.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1500));
}
} catch (error) {
console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${filamentId}`;
onSaveNote(profileKey, note);
}
} catch (error) {
console.error('[KProfile] Failed to save batch:', error);
showToast('Failed to save K-profiles', 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
}
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
// Wait for final sync before closing
// onSave will trigger refetch in the parent component
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
}, 2500);
}, 3000);
};
return (
@@ -425,7 +492,7 @@ function KProfileModal({
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
@@ -502,6 +569,21 @@ function KProfileModal({
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add notes about this profile..."
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 Bambusy, not on the printer
</p>
</div>
<div className="flex gap-2 pt-4">
{profile && (
<Button
@@ -595,15 +677,52 @@ function KProfileModal({
type ExtruderFilter = 'all' | 'left' | 'right';
type FlowTypeFilter = 'all' | 'hf' | 's';
type SortOption = 'name' | 'k_value' | 'filament';
// localStorage keys
const STORAGE_KEYS = {
NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',
SORT_OPTION: 'bambusy_kprofiles_sort',
};
export function KProfilesView() {
const { showToast } = useToast();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
// Load nozzle diameter from localStorage
const [nozzleDiameter, setNozzleDiameter] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);
return saved || '0.4';
});
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
// Load sort option from localStorage
const [sortOption, setSortOption] = useState<SortOption>(() => {
const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);
return (saved as SortOption) || 'name';
});
// Bulk selection mode
// Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders
const [selectionMode, setSelectionMode] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
// Helper to create unique profile key for selection
const getProfileKey = (profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`;
// Save nozzle diameter to localStorage when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);
}, [nozzleDiameter]);
// Save sort option to localStorage when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);
}, [sortOption]);
// Get available printers
const { data: printers, isLoading: printersLoading } = useQuery({
@@ -611,21 +730,47 @@ export function KProfilesView() {
queryFn: api.getPrinters,
});
// Get K-profiles for selected printer
// Get K-profiles for selected printer (filtered by nozzle diameter)
const {
data: kprofiles,
isLoading: kprofilesLoading,
isFetching,
error: kprofilesError,
refetch: refetchProfiles,
} = useQuery({
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
queryFn: async () => {
console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);
const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);
console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');
return result;
},
enabled: !!selectedPrinter,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh fetch
gcTime: 0, // Don't cache results
refetchOnMount: 'always', // Always refetch when component mounts
});
// Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
const { data: allProfiles } = useQuery({
queryKey: ['kprofiles', selectedPrinter, '0.4'],
queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
enabled: !!selectedPrinter,
staleTime: 60000, // Cache for 1 minute
});
// Fetch K-profile notes (stored locally)
const {
data: notesData,
refetch: refetchNotes,
} = useQuery({
queryKey: ['kprofile-notes', selectedPrinter],
queryFn: () => api.getKProfileNotes(selectedPrinter!),
enabled: !!selectedPrinter,
staleTime: 30000, // Cache for 30 seconds
});
// Check if error is due to printer not being connected
const isOfflineError = kprofilesError?.message?.includes('not connected');
@@ -653,11 +798,12 @@ export function KProfilesView() {
// Get connected printers for display
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
// Filter profiles based on search query, extruder filter, and flow type
// Filter and sort profiles
// Note: nozzle diameter filtering is done server-side via MQTT request
const filteredProfiles = React.useMemo(() => {
if (!kprofiles?.profiles) return [];
return kprofiles.profiles.filter((p) => {
const filtered = kprofiles.profiles.filter((p) => {
// Search filter - match name or filament_id (case-insensitive)
const query = searchQuery.toLowerCase();
const matchesSearch =
@@ -679,12 +825,239 @@ export function KProfilesView() {
return matchesSearch && matchesExtruder && matchesFlowType;
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
// Sort profiles
return filtered.sort((a, b) => {
switch (sortOption) {
case 'k_value':
return parseFloat(a.k_value) - parseFloat(b.k_value);
case 'filament':
return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
return;
}
// Don't trigger when modal is open
if (editingProfile || showAddModal || copyingProfile) {
return;
}
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
refetchProfiles();
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setShowAddModal(true);
} else if (e.key === 'Escape' && selectionMode) {
e.preventDefault();
setSelectionMode(false);
setSelectedProfiles(new Set());
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);
// Export profiles to JSON file
const handleExport = useCallback(() => {
if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
showToast('No profiles to export', 'error');
return;
}
const exportData = {
version: 1,
exported_at: new Date().toISOString(),
printer: selectedPrinterData?.name || 'Unknown',
nozzle_diameter: nozzleDiameter,
profiles: kprofiles.profiles.map(p => ({
name: p.name,
k_value: p.k_value,
filament_id: p.filament_id,
nozzle_id: p.nozzle_id,
nozzle_diameter: p.nozzle_diameter,
extruder_id: p.extruder_id,
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${kprofiles.profiles.length} profiles`);
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
// Import profiles from JSON file
const handleImport = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.profiles || !Array.isArray(data.profiles)) {
showToast('Invalid file format', 'error');
return;
}
// Import profiles one by one
let imported = 0;
for (const p of data.profiles) {
if (!p.name || !p.k_value || !p.filament_id) continue;
try {
await api.setKProfile(selectedPrinter!, {
name: p.name,
k_value: parseFloat(p.k_value).toFixed(6),
filament_id: p.filament_id,
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
extruder_id: p.extruder_id ?? 0,
slot_id: 0, // Always create new
});
imported++;
// Small delay between imports
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
console.error('Failed to import profile:', p.name, err);
}
}
showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
refetchProfiles();
} catch (err) {
console.error('Import error:', err);
showToast('Failed to parse import file', 'error');
}
};
input.click();
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
// Toggle profile selection using composite key
const toggleProfileSelection = useCallback((profileKey: string) => {
setSelectedProfiles(prev => {
const next = new Set(prev);
if (next.has(profileKey)) {
next.delete(profileKey);
} else {
next.add(profileKey);
}
return next;
});
}, []);
// Select all visible profiles
const selectAllProfiles = useCallback(() => {
setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));
}, [filteredProfiles, getProfileKey]);
// Delete selected profiles
const handleBulkDelete = useCallback(() => {
if (selectedProfiles.size === 0) return;
setShowBulkDeleteConfirm(true);
}, [selectedProfiles.size]);
// Execute the actual bulk delete
const executeBulkDelete = useCallback(async () => {
const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));
setBulkDeleteInProgress(true);
let deleted = 0;
for (const profile of profilesToDelete) {
try {
await api.deleteKProfile(selectedPrinter!, {
slot_id: profile.slot_id,
extruder_id: profile.extruder_id,
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
setting_id: profile.setting_id,
});
deleted++;
// Small delay between deletes
await new Promise(resolve => setTimeout(resolve, 300));
} catch (err) {
console.error('Failed to delete profile:', profile.name, err);
}
}
showToast(`Deleted ${deleted} profiles`);
setBulkDeleteInProgress(false);
setShowBulkDeleteConfirm(false);
setSelectionMode(false);
setSelectedProfiles(new Set());
refetchProfiles();
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
// Generate possible keys for a profile (for notes lookup)
// Returns array of keys to check: setting_id, slot-based, name-based
const getProfileKeys = useCallback((profile: KProfile): string[] => {
const keys: string[] = [];
if (profile.setting_id) {
keys.push(profile.setting_id);
}
// Slot-based key (for profiles without setting_id)
keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);
// Name-based key (for newly created profiles)
keys.push(`name_${profile.name}_${profile.filament_id}`);
return keys;
}, []);
// Save note for a profile
const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {
if (!selectedPrinter) return;
try {
await api.setKProfileNote(selectedPrinter, profileKey, noteText);
refetchNotes();
} catch (err) {
console.error('Failed to save note:', err);
showToast('Failed to save note', 'error');
}
}, [selectedPrinter, refetchNotes, showToast]);
// Get note for a profile (checks all possible keys)
// Returns { note, key } so we know which key the note was stored under
const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {
if (!notesData?.notes) return { note: '', key: null };
const keys = getProfileKeys(profile);
for (const key of keys) {
if (notesData.notes[key]) {
return { note: notesData.notes[key], key };
}
}
return { note: '', key: null };
}, [notesData, getProfileKeys]);
// Simple getter for display purposes
const getNote = useCallback((profile: KProfile) => {
return getNoteWithKey(profile).note;
}, [getNoteWithKey]);
if (printersLoading) {
return (
<div className="flex justify-center py-12">
@@ -723,6 +1096,14 @@ export function KProfilesView() {
return (
<>
{/* Loading overlay when refetching profiles (not initial load) */}
{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>
</div>
)}
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
@@ -758,9 +1139,9 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={() => refetchProfiles()}
disabled={kprofilesLoading}
disabled={isFetching}
>
<RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddModal(true)}>
@@ -771,7 +1152,7 @@ export function KProfilesView() {
</div>
{/* Search & Filter Row */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex flex-wrap gap-4 mb-4">
<div className="flex-1 min-w-48 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
@@ -806,6 +1187,81 @@ export function KProfilesView() {
<option value="s">S Only</option>
</select>
</div>
<div className="w-32">
<select
value={sortOption}
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>
</select>
</div>
</div>
{/* Toolbar Row */}
<div className="flex flex-wrap gap-2 mb-6">
<Button
variant="secondary"
onClick={handleExport}
disabled={!kprofiles?.profiles?.length}
title="Export profiles to JSON"
>
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="secondary"
onClick={handleImport}
title="Import profiles from JSON"
>
<Upload className="w-4 h-4" />
Import
</Button>
<div className="flex-1" />
{selectionMode ? (
<>
<Button
variant="secondary"
onClick={selectAllProfiles}
title="Select all visible profiles"
>
<CheckSquare className="w-4 h-4" />
Select All
</Button>
<Button
variant="secondary"
onClick={handleBulkDelete}
disabled={selectedProfiles.size === 0}
className="text-red-500 hover:bg-red-500/10"
title={`Delete ${selectedProfiles.size} selected profiles`}
>
<Trash2 className="w-4 h-4" />
Delete ({selectedProfiles.size})
</Button>
<Button
variant="secondary"
onClick={() => {
setSelectionMode(false);
setSelectedProfiles(new Set());
}}
>
<X className="w-4 h-4" />
Cancel
</Button>
</>
) : (
<Button
variant="secondary"
onClick={() => setSelectionMode(true)}
disabled={!filteredProfiles.length}
title="Enter selection mode for bulk delete"
>
<CheckSquare className="w-4 h-4" />
Select
</Button>
)}
</div>
{/* K-Profiles Grid */}
@@ -839,9 +1295,14 @@ export function KProfilesView() {
.filter((p) => p.extruder_id === 1)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -854,9 +1315,14 @@ export function KProfilesView() {
.filter((p) => p.extruder_id === 0)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -867,9 +1333,14 @@ export function KProfilesView() {
<div className="space-y-1">
{filteredProfiles.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -901,29 +1372,118 @@ export function KProfilesView() {
)}
{/* Edit Modal */}
{editingProfile && selectedPrinter && (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setEditingProfile(null)}
onSave={() => setEditingProfile(null)}
/>
)}
{editingProfile && selectedPrinter && (() => {
const { note, key } = getNoteWithKey(editingProfile);
return (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
initialNote={note}
initialNoteKey={key}
onSaveNote={handleSaveNote}
onClose={() => {
console.log('[KProfiles] Edit modal onClose - refetching profiles...');
setEditingProfile(null);
refetchProfiles(); // Refetch after close (handles delete case)
}}
onSave={() => {
setEditingProfile(null);
refetchProfiles();
}}
/>
);
})()}
{/* Add Modal */}
{showAddModal && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setShowAddModal(false)}
onSave={() => setShowAddModal(false)}
onSaveNote={handleSaveNote}
onClose={() => {
setShowAddModal(false);
refetchProfiles(); // Refetch after close
}}
onSave={() => {
setShowAddModal(false);
refetchProfiles();
}}
/>
)}
{/* Copy Modal - opens add modal with prefilled values from source profile */}
{copyingProfile && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
// Pass profile data but without slot_id to create a new profile
profile={{
...copyingProfile,
slot_id: 0, // Force new profile creation
name: `${copyingProfile.name} (Copy)`, // Indicate it's a copy
}}
onClose={() => {
setCopyingProfile(null);
refetchProfiles();
}}
onSave={() => {
setCopyingProfile(null);
refetchProfiles();
}}
/>
)}
{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<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>
</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?
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowBulkDeleteConfirm(false)}
disabled={bulkDeleteInProgress}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={executeBulkDelete}
disabled={bulkDeleteInProgress}
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
>
{bulkDeleteInProgress ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</>
);
}
@@ -37,6 +37,11 @@ function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => strin
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'K-Profiles', items: [
{ keys: ['R'], description: 'Refresh profiles' },
{ keys: ['N'], description: 'New profile' },
{ keys: ['Esc'], description: 'Exit selection mode' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
+5 -4
View File
@@ -1088,6 +1088,11 @@ export function ArchivesPage() {
{/* Selection Toolbar */}
{selectionMode && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
<Button variant="secondary" size="sm" onClick={clearSelection}>
<X className="w-4 h-4" />
Close
</Button>
<div className="w-px h-6 bg-bambu-dark-tertiary" />
<span className="text-white font-medium">
{selectedIds.size} selected
</span>
@@ -1095,10 +1100,6 @@ export function ArchivesPage() {
<Button variant="secondary" size="sm" onClick={selectAll}>
Select All
</Button>
<Button variant="secondary" size="sm" onClick={clearSelection}>
<X className="w-4 h-4" />
Clear
</Button>
<div className="w-px h-6 bg-bambu-dark-tertiary" />
<Button
variant="secondary"
File diff suppressed because it is too large Load Diff
+593 -155
View File
@@ -1,6 +1,23 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Clock,
Trash2,
@@ -16,17 +33,32 @@ import {
ExternalLink,
Power,
StopCircle,
Pencil,
RefreshCw,
Timer,
ListOrdered,
Layers,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem } from '../api/client';
import { Card } from '../components/Card';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { EditQueueItemModal } from '../components/EditQueueItemModal';
import { useToast } from '../contexts/ToastContext';
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatRelativeTime(dateString: string | null): string {
if (!dateString) return 'ASAP';
// Parse ISO string - it's in UTC, convert to local for display
const date = new Date(dateString);
const now = new Date();
const diff = date.getTime() - now.getTime();
@@ -41,48 +73,91 @@ function formatRelativeTime(dateString: string | null): string {
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
const config = {
pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10', label: 'Printing' },
completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10', label: 'Completed' },
failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10', label: 'Failed' },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10', label: 'Skipped' },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10', label: 'Cancelled' },
pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10 border-green-400/20', label: 'Completed' },
failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10 border-red-400/20', label: 'Failed' },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
};
const { icon: Icon, color, label } = config[status];
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${color}`}>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
);
}
function QueueItemRow({
// Sortable queue item for drag and drop
function SortableQueueItem({
item,
position,
onEdit,
onCancel,
onRemove,
onStop,
onRequeue,
}: {
item: PrintQueueItem;
onCancel: (id: number) => void;
onRemove: (id: number) => void;
onStop: (id: number) => void;
position?: number;
onEdit: () => void;
onCancel: () => void;
onRemove: () => void;
onStop: () => void;
onRequeue: () => void;
}) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: item.status !== 'pending' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isPrinting = item.status === 'printing';
const isPending = item.status === 'pending';
const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
return (
<>
<div className="flex items-center gap-4 p-4 bg-bambu-dark-secondary rounded-lg">
{item.status === 'pending' && (
<GripVertical className="w-5 h-5 text-bambu-gray cursor-grab" />
<div
ref={setNodeRef}
style={style}
className={`
group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
transition-all duration-200 hover:border-bambu-dark-tertiary/80
${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
`}
>
<div className="flex items-center gap-4 p-4">
{/* Drag handle or position number */}
{isPending ? (
<div
{...attributes}
{...listeners}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</div>
) : position !== undefined ? (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
#{position}
</div>
) : (
<div className="w-8" />
)}
{/* Thumbnail */}
<div className="w-16 h-16 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={api.getArchiveThumbnail(item.archive_id)}
@@ -91,14 +166,14 @@ function QueueItemRow({
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Calendar className="w-6 h-6" />
<Layers className="w-6 h-6" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-1">
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
</p>
@@ -110,119 +185,121 @@ function QueueItemRow({
<ExternalLink className="w-3.5 h-3.5" />
</Link>
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-bambu-gray">
<span className="flex items-center gap-1">
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className="flex items-center gap-1.5">
<Printer className="w-3.5 h-3.5" />
{item.printer_name || `Printer #${item.printer_id}`}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
<Timer className="w-3.5 h-3.5" />
{formatDuration(item.print_time_seconds)}
</span>
)}
{isPending && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time)}
</span>
)}
</div>
{item.require_previous_success && (
<p className="text-xs text-orange-400 mt-1">
Requires previous print to succeed
</p>
)}
{item.auto_off_after && (
<p className="text-xs text-blue-400 mt-1 flex items-center gap-1">
<Power className="w-3 h-3" />
Will power off when done
</p>
{/* Options badges */}
<div className="flex items-center gap-2 mt-2">
{item.require_previous_success && (
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
Requires previous success
</span>
)}
{item.auto_off_after && (
<span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
<Power className="w-3 h-3" />
Auto power off
</span>
)}
</div>
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
{isPrinting && (
<div className="mt-3">
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
</div>
<p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
</div>
)}
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-1 flex items-center gap-1">
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{item.error_message}
</p>
)}
</div>
{/* Status */}
{/* Status badge */}
<StatusBadge status={item.status} />
{/* Actions */}
<div className="flex items-center gap-2">
{item.status === 'printing' && (
<div className="flex items-center gap-1">
{isPrinting && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirm(true)}
onClick={onStop}
title="Stop Print"
className="text-red-400 hover:text-red-300"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<StopCircle className="w-4 h-4" />
</Button>
)}
{item.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCancelConfirm(true)}
title="Cancel"
>
<X className="w-4 h-4" />
</Button>
{isPending && (
<>
<Button
variant="ghost"
size="sm"
onClick={onEdit}
title="Edit"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCancel}
title="Cancel"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="w-4 h-4" />
</Button>
</>
)}
{['completed', 'failed', 'skipped', 'cancelled'].includes(item.status) && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowRemoveConfirm(true)}
title="Remove"
>
<Trash2 className="w-4 h-4" />
</Button>
{isHistory && (
<>
<Button
variant="ghost"
size="sm"
onClick={onRequeue}
title="Re-queue"
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
title="Remove"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
{/* Cancel Confirmation Modal */}
{showCancelConfirm && (
<ConfirmModal
title="Cancel Scheduled Print"
message={`Are you sure you want to cancel "${item.archive_name || 'this print'}"? It will be removed from the queue.`}
confirmText="Cancel Print"
variant="danger"
onConfirm={() => {
onCancel(item.id);
setShowCancelConfirm(false);
}}
onCancel={() => setShowCancelConfirm(false)}
/>
)}
{/* Remove Confirmation Modal */}
{showRemoveConfirm && (
<ConfirmModal
title="Remove from History"
message={`Are you sure you want to remove "${item.archive_name || 'this item'}" from the queue history?`}
confirmText="Remove"
variant="danger"
onConfirm={() => {
onRemove(item.id);
setShowRemoveConfirm(false);
}}
onCancel={() => setShowRemoveConfirm(false)}
/>
)}
{/* Stop Confirmation Modal */}
{showStopConfirm && (
<ConfirmModal
title="Stop Print"
message={`Are you sure you want to stop the current print "${item.archive_name || 'this print'}"? This will cancel the print job on the printer.`}
confirmText="Stop Print"
variant="danger"
onConfirm={() => {
onStop(item.id);
setShowStopConfirm(false);
}}
onCancel={() => setShowStopConfirm(false)}
/>
)}
</>
</div>
);
}
@@ -231,11 +308,55 @@ export function QueuePage() {
const { showToast } = useToast();
const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('');
const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
const [confirmAction, setConfirmAction] = useState<{
type: 'cancel' | 'remove' | 'stop';
item: PrintQueueItem;
} | null>(null);
const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
const saved = localStorage.getItem('queue.historySortBy');
return (saved as 'date' | 'name' | 'printer') || 'date';
});
const [historySortAsc, setHistorySortAsc] = useState(() => {
const saved = localStorage.getItem('queue.historySortAsc');
return saved !== null ? saved === 'true' : false;
});
const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
const saved = localStorage.getItem('queue.pendingSortBy');
return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
});
const [pendingSortAsc, setPendingSortAsc] = useState(() => {
const saved = localStorage.getItem('queue.pendingSortAsc');
return saved !== null ? saved === 'true' : true;
});
// Persist sort settings to localStorage
useEffect(() => {
localStorage.setItem('queue.historySortBy', historySortBy);
}, [historySortBy]);
useEffect(() => {
localStorage.setItem('queue.historySortAsc', String(historySortAsc));
}, [historySortAsc]);
useEffect(() => {
localStorage.setItem('queue.pendingSortBy', pendingSortBy);
}, [pendingSortBy]);
useEffect(() => {
localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
}, [pendingSortAsc]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const { data: queue, isLoading } = useQuery({
queryKey: ['queue', filterPrinter, filterStatus],
queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
refetchInterval: 10000,
refetchInterval: 5000,
});
const { data: printers } = useQuery({
@@ -270,23 +391,195 @@ export function QueuePage() {
onError: () => showToast('Failed to stop print', 'error'),
});
const pendingItems = queue?.filter(i => i.status === 'pending') || [];
const reorderMutation = useMutation({
mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
},
onError: () => showToast('Failed to reorder queue', 'error'),
});
const requeueMutation = useMutation({
mutationFn: (item: PrintQueueItem) => {
// Schedule far in future so it doesn't start immediately
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
return api.addToQueue({
printer_id: item.printer_id,
archive_id: item.archive_id,
scheduled_time: futureDate.toISOString(),
require_previous_success: false,
auto_off_after: false,
});
},
onSuccess: (newItem) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Added back to queue - please set schedule');
// Open edit modal for the new item
setEditItem(newItem);
},
onError: (error: Error) => showToast(error.message || 'Failed to re-queue item', 'error'),
});
const clearHistoryMutation = useMutation({
mutationFn: async () => {
const historyItems = queue?.filter(i =>
['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
) || [];
for (const item of historyItems) {
await api.removeFromQueue(item.id);
}
return historyItems.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
},
onError: () => showToast('Failed to clear history', 'error'),
});
const pendingItems = useMemo(() => {
const items = queue?.filter(i => i.status === 'pending') || [];
// Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
const getScheduledTime = (item: PrintQueueItem): number => {
if (!item.scheduled_time) return 0;
const time = new Date(item.scheduled_time).getTime();
// Placeholder dates (> 6 months out) are treated as ASAP
const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
return time > sixMonthsFromNow ? 0 : time;
};
return [...items].sort((a, b) => {
let cmp: number;
if (pendingSortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
} else if (pendingSortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else if (pendingSortBy === 'time') {
// Sort by scheduled start time (when print will begin)
cmp = getScheduledTime(a) - getScheduledTime(b);
} else {
cmp = a.position - b.position;
}
return pendingSortAsc ? cmp : -cmp;
});
}, [queue, pendingSortBy, pendingSortAsc]);
const activeItems = queue?.filter(i => i.status === 'printing') || [];
const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
const historyItems = useMemo(() => {
const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
return [...items].sort((a, b) => {
let cmp: number;
if (historySortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
} else if (historySortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else {
// Default: by date - most recent first (desc) is the natural order
cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
}
return historySortAsc ? -cmp : cmp;
});
}, [queue, historySortBy, historySortAsc]);
// Calculate total queue time
const totalQueueTime = useMemo(() => {
return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
}, [pendingItems]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pendingItems.findIndex(i => i.id === active.id);
const newIndex = pendingItems.findIndex(i => i.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(pendingItems, oldIndex, newIndex);
const updates = reordered.map((item, index) => ({
id: item.id,
position: index + 1,
}));
reorderMutation.mutate(updates);
}
};
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Print Queue</h1>
<p className="text-bambu-gray mt-1">Schedule and manage print jobs</p>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<ListOrdered className="w-7 h-7 text-bambu-green" />
Print Queue
</h1>
<p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Play className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{activeItems.length}</p>
<p className="text-sm text-bambu-gray">Printing</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Clock className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{pendingItems.length}</p>
<p className="text-sm text-bambu-gray">Queued</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
<Timer className="w-5 h-5 text-bambu-green" />
</div>
<div>
<p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
<p className="text-sm text-bambu-gray">Total Queue Time</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{historyItems.length}</p>
<p className="text-sm text-bambu-gray">History</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4 mb-6">
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterPrinter || ''}
onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
>
@@ -297,7 +590,7 @@ export function QueuePage() {
</select>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
@@ -309,35 +602,51 @@ export function QueuePage() {
<option value="skipped">Skipped</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="flex-1" />
{historyItems.length > 0 && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearHistoryConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear History
</Button>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">Loading...</div>
) : queue?.length === 0 ? (
<Card className="p-12 text-center">
<Calendar className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No prints scheduled</h3>
<p className="text-bambu-gray">
Schedule a print from the Archives page using the "Schedule" option in the context menu.
<Card className="p-12 text-center border-dashed">
<Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
<h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
<p className="text-bambu-gray max-w-md mx-auto">
Schedule a print from the Archives page using the "Schedule" option in the context menu,
or drag and drop files to get started.
</p>
</Card>
) : (
<div className="space-y-6">
<div className="space-y-8">
{/* Active Prints */}
{activeItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Play className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Currently Printing
</h2>
<div className="space-y-2">
<div className="space-y-3">
{activeItems.map((item) => (
<QueueItemRow
<SortableQueueItem
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
onEdit={() => {}}
onCancel={() => {}}
onRemove={() => {}}
onStop={() => setConfirmAction({ type: 'stop', item })}
onRequeue={() => {}}
/>
))}
</div>
@@ -347,39 +656,110 @@ export function QueuePage() {
{/* Pending Queue */}
{pendingItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
Queued ({pendingItems.length})
</h2>
<div className="space-y-2">
{pendingItems.map((item) => (
<QueueItemRow
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
/>
))}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
Queued
<span className="text-sm font-normal text-bambu-gray">
({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
</span>
<span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
Drag to reorder (ASAP only)
</span>
</h2>
<div className="flex items-center gap-2">
<select
className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={pendingSortBy}
onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
>
<option value="position">Sort by Position</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
<option value="time">Sort by Schedule</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setPendingSortAsc(!pendingSortAsc)}
title={pendingSortAsc ? 'Ascending' : 'Descending'}
className="px-2"
>
{pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pendingItems.map(i => i.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{pendingItems.map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
position={index + 1}
onEdit={() => setEditItem(item)}
onCancel={() => setConfirmAction({ type: 'cancel', item })}
onRemove={() => {}}
onStop={() => {}}
onRequeue={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* History */}
{historyItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-bambu-gray" />
History ({historyItems.length})
</h2>
<div className="space-y-2">
{historyItems.slice(0, 10).map((item) => (
<QueueItemRow
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-bambu-gray" />
History
<span className="text-sm font-normal text-bambu-gray">
({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
</span>
</h2>
<div className="flex items-center gap-2">
<select
className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={historySortBy}
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setHistorySortAsc(!historySortAsc)}
title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
className="px-2"
>
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-3">
{historyItems.slice(0, 20).map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
position={index + 1}
onEdit={() => {}}
onCancel={() => {}}
onRemove={() => setConfirmAction({ type: 'remove', item })}
onStop={() => {}}
onRequeue={() => requeueMutation.mutate(item)}
/>
))}
</div>
@@ -387,6 +767,64 @@ export function QueuePage() {
)}
</div>
)}
{/* Edit Modal */}
{editItem && (
<EditQueueItemModal
item={editItem}
onClose={() => setEditItem(null)}
/>
)}
{/* Confirm Action Modal */}
{confirmAction && (
<ConfirmModal
title={
confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove from History'
}
message={
confirmAction.type === 'cancel'
? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
: confirmAction.type === 'stop'
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
: `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
}
confirmText={
confirmAction.type === 'cancel' ? 'Cancel Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove'
}
variant="danger"
onConfirm={() => {
if (confirmAction.type === 'cancel') {
cancelMutation.mutate(confirmAction.item.id);
} else if (confirmAction.type === 'stop') {
stopMutation.mutate(confirmAction.item.id);
} else {
removeMutation.mutate(confirmAction.item.id);
}
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{/* Clear History Confirm Modal */}
{showClearHistoryConfirm && (
<ConfirmModal
title="Clear History"
message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
confirmText="Clear History"
variant="danger"
onConfirm={() => {
clearHistoryMutation.mutate();
setShowClearHistoryConfirm(false);
}}
onCancel={() => setShowClearHistoryConfirm(false)}
/>
)}
</div>
);
}