mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
sync
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
]},
|
||||
|
||||
@@ -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"
|
||||
|
||||
+924
-207
File diff suppressed because it is too large
Load Diff
+593
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user