Merge branch '0.2.1b' into feature/brazilianPortugueseTranslation

This commit is contained in:
Wesley Reuel Marques Silva
2026-02-18 13:33:50 +00:00
committed by GitHub
13 changed files with 501 additions and 406 deletions
@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
rgba: 'FF0000FF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 300,
slicer_filament: 'GFL99',
slicer_filament_name: 'Generic PLA',
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
// weight_used MUST be included for new spools (default value 0)
expect(payload).toHaveProperty('weight_used', 0);
});
it('preserves core_weight_catalog_id when editing other fields', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
core_weight_catalog_id: 5,
};
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
// Change the note field (unrelated to catalog ID)
const noteInputs = screen.getAllByPlaceholderText(/note/i);
expect(noteInputs.length).toBeGreaterThan(0);
fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
// Click Save
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(api.updateSpool).toHaveBeenCalledTimes(1);
});
const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
expect(spoolId).toBe(1);
// core_weight_catalog_id MUST be preserved when editing other fields
expect(payload).toHaveProperty('core_weight_catalog_id', 5);
// Other changes should also be present
expect(payload).toHaveProperty('note', 'Updated note');
});
it('includes core_weight_catalog_id when selecting from catalog', async () => {
const mockCatalog = [
{ id: 1, name: 'Generic 250g', weight: 250 },
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
{ id: 3, name: 'Standard 300g', weight: 300 },
];
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
// Wait for catalog to load
await waitFor(() => {
expect(api.getSpoolCatalog).toHaveBeenCalled();
});
// Click on the empty spool weight field to open dropdown
const weightInputs = screen.getAllByPlaceholderText(/search/i);
const weightPicker = weightInputs.find(input =>
input.getAttribute('placeholder')?.toLowerCase().includes('spool')
);
expect(weightPicker).toBeTruthy();
fireEvent.focus(weightPicker!);
// Click on "Bambu Lab 250g" option
const bambuOption = await screen.findByText('Bambu Lab 250g');
fireEvent.click(bambuOption);
// Click the add spool button
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton!);
await waitFor(() => {
expect(api.createSpool).toHaveBeenCalledTimes(1);
});
const [payload] = vi.mocked(api.createSpool).mock.calls[0];
// Both weight AND catalog ID should be sent
expect(payload).toHaveProperty('core_weight', 250);
expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
});
it('displays correct catalog name when duplicates exist', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
core_weight: 250,
core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
};
const mockCatalog = [
{ id: 1, name: 'Generic 250g', weight: 250 },
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
{ id: 3, name: 'Standard 300g', weight: 300 },
];
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
// Wait for catalog to load
await waitFor(() => {
expect(api.getSpoolCatalog).toHaveBeenCalled();
});
// Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
await waitFor(() => {
const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
const bambuFound = weightInputs.some(input =>
input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
);
expect(bambuFound).toBeTruthy();
});
});
});
+1
View File
@@ -1771,6 +1771,7 @@ export interface InventorySpool {
brand: string | null;
label_weight: number;
core_weight: number;
core_weight_catalog_id: number | null;
weight_used: number;
slicer_filament: string | null;
slicer_filament_name: string | null;
@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: spool.rgba || '808080FF',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
weight_used: spool.weight_used || 0,
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: formData.rgba || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
@@ -8,15 +8,18 @@ function SpoolWeightPicker({
catalog,
value,
onChange,
catalogId,
onCatalogIdChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
catalogId: number | null;
onCatalogIdChange: (id: number | null) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// When value changes, auto-select if there's only one matching entry or keep selection if it still matches
useEffect(() => {
// If no catalog loaded yet, skip matching logic
if (catalog.length === 0) {
return;
}
const matches = catalog.filter(e => e.weight === value);
// If currently selected entry still matches the weight, keep it selected
if (catalogId) {
const selected = catalog.find(e => e.id === catalogId);
if (selected && selected.weight === value) {
return; // Keep current selection
}
}
// If exactly one match, auto-select it
if (matches.length === 1) {
onCatalogIdChange(matches[0].id);
} else if (matches.length === 0) {
// No matches, clear selection to prevent stale catalog ID
if (catalogId !== null) {
onCatalogIdChange(null);
}
}
// If multiple matches, don't auto-select - let user choose
}, [value, catalog, catalogId, onCatalogIdChange]);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
);
}, [catalog, search]);
// Display value: show catalog name if selected, or the weight
// Find all entries matching the current weight
const matchingEntries = useMemo(() => {
return catalog.filter(e => e.weight === value);
}, [catalog, value]);
// Display value: show catalog name if selected by ID, otherwise show first match
const displayValue = useMemo(() => {
if (isOpen) return search;
if (selectedId) {
const entry = catalog.find(e => e.id === selectedId);
// If a catalog ID is explicitly selected, use that
if (catalogId) {
const entry = catalog.find(e => e.id === catalogId);
if (entry) return entry.name;
}
const match = catalog.find(e => e.weight === value);
if (match) return match.name;
// Otherwise, show the first matching entry as a suggestion
if (matchingEntries.length > 0) {
return matchingEntries[0].name;
}
// Leave empty if there are no matches
return '';
}, [isOpen, search, selectedId, catalog, value]);
}, [isOpen, search, catalogId, catalog, matchingEntries]);
return (
<div>
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(selectedId ? entry.id === selectedId : entry.weight === value)
(catalogId ? entry.id === catalogId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
setSelectedId(entry.id);
onCatalogIdChange(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
@@ -150,6 +194,8 @@ export function AdditionalSection({
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
catalogId={formData.core_weight_catalog_id}
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
/>
{/* Current Weight (remaining filament) */}
@@ -9,6 +9,7 @@ export interface SpoolFormData {
rgba: string;
label_weight: number;
core_weight: number;
core_weight_catalog_id: number | null;
weight_used: number;
slicer_filament: string;
note: string;
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: '',
note: '',
+196 -175
View File
@@ -361,26 +361,40 @@ function SortableQueueItem({
const isPending = item.status === 'pending';
const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
const isMobileSelectable = isPending && onToggleSelect;
return (
<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
group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
${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' : ''}
${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
${isMobileSelectable ? 'sm:cursor-default' : ''}
`}
onClick={isMobileSelectable ? () => {
if (window.innerWidth < 640) onToggleSelect();
} : undefined}
>
<div className="flex items-center gap-4 p-4">
{/* Selection checkbox for pending items */}
{/* Mobile selected left accent bar */}
{isMobileSelectable && isSelected && (
<div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
)}
<div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
{/* Mobile selection indicator — left accent bar only, no tick */}
{/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
{isPending && onToggleSelect && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className={`flex items-center justify-center w-6 h-6 rounded border transition-colors ${
className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
isSelected
? 'bg-bambu-green border-bambu-green text-white'
: 'border-white/30 bg-black/30 hover:border-bambu-green/50'
@@ -390,25 +404,25 @@ function SortableQueueItem({
</button>
)}
{/* Drag handle or position number */}
{/* Drag handle or position number - hidden on mobile */}
{isPending ? (
<div
{...attributes}
{...listeners}
className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
className="hidden sm: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 touch-manipulation shrink-0"
>
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
<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">
<div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0">
#{position}
</div>
) : (
<div className="w-8" />
<div className="hidden sm:block w-8 shrink-0" />
)}
{/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
<div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={
@@ -431,7 +445,7 @@ function SortableQueueItem({
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Layers className="w-6 h-6" />
<Layers className="w-5 h-5 sm:w-6 sm:h-6" />
</div>
)}
</div>
@@ -439,7 +453,7 @@ function SortableQueueItem({
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-white font-medium truncate">
<p className="text-sm sm:text-base text-white font-medium truncate">
{item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
{(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
</p>
@@ -462,57 +476,59 @@ function SortableQueueItem({
) : null}
</div>
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
<span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="truncate max-w-[120px] sm:max-w-none">
{item.target_model
? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? t('queue.filter.unassigned')
: (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
</span>
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
<Timer className="w-3.5 h-3.5" />
<span className="flex items-center gap-1 sm:gap-1.5">
<Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
{formatDuration(item.print_time_seconds)}
</span>
)}
{item.filament_used_grams && (
<span className="flex items-center gap-1.5">
<Weight className="w-3.5 h-3.5" />
<span className="flex items-center gap-1 sm:gap-1.5">
<Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
{formatWeight(item.filament_used_grams)}
</span>
)}
{item.created_by_username && (
<span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
<span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
<User className="w-3.5 h-3.5" />
{item.created_by_username}
</span>
)}
{isPending && !item.manual_start && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span className="flex items-center gap-1 sm:gap-1.5">
<Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
{formatRelativeTime(item.scheduled_time, timeFormat, t)}
</span>
)}
</div>
{/* Options badges */}
<div className="flex items-center gap-2 mt-2">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
{item.manual_start && (
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
<Hand className="w-3 h-3" />
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
<Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
{t('queue.badges.staged')}
</span>
)}
{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">
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
{t('queue.badges.requiresPrevious')}
</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" />
<span className="text-[10px] sm:text-xs px-1.5 sm: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-2.5 h-2.5 sm:w-3 sm:h-3" />
{t('queue.badges.autoPowerOff')}
</span>
)}
@@ -520,17 +536,17 @@ function SortableQueueItem({
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
{isPrinting && status && (
<div className="mt-3">
<div className="flex items-center justify-between text-sm">
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
<div className="mt-2 sm:mt-3">
<div className="flex items-center justify-between text-xs sm:text-sm">
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
<div
className="bg-bambu-green h-2 rounded-full transition-all"
className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
style={{ width: `${status.progress || 0}%` }}
/>
</div>
<span className="text-white">{Math.round(status.progress || 0)}%</span>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray">
{status.remaining_time != null && status.remaining_time > 0 && (
<>
<span className="flex items-center gap-1">
@@ -554,7 +570,7 @@ function SortableQueueItem({
{/* Waiting reason for model-based assignments */}
{item.waiting_reason && item.status === 'pending' && (
<p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
<p className="text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
<AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{item.waiting_reason}</span>
</p>
@@ -562,88 +578,92 @@ function SortableQueueItem({
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
<p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{item.error_message}
</p>
)}
</div>
{/* Status badge */}
<StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
{/* Status badge + Actions */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
{/* Actions */}
<div className="flex items-center gap-1">
{isPrinting && (
<Button
variant="ghost"
size="sm"
onClick={onStop}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<StopCircle className="w-4 h-4" />
</Button>
)}
{isPending && (
<>
{item.manual_start && (
<div className="flex items-center gap-0.5 sm:gap-1">
{isPrinting && (
<Button
variant="ghost"
size="sm"
onClick={onStop}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
>
<StopCircle className="w-4 h-4" />
</Button>
)}
{isPending && (
<>
{item.manual_start && (
<Button
variant="ghost"
size="sm"
onClick={onStart}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
>
<Play className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onStart}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
onClick={onEdit}
disabled={!canModify('queue', 'update', item.created_by_id)}
title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
className="p-1.5 sm:p-2"
>
<Play className="w-4 h-4" />
<Pencil className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onEdit}
disabled={!canModify('queue', 'update', item.created_by_id)}
title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="w-4 h-4" />
</Button>
</>
)}
{isHistory && (
<>
<Button
variant="ghost"
size="sm"
onClick={onRequeue}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
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}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
>
<X className="w-4 h-4" />
</Button>
</>
)}
{isHistory && (
<>
<Button
variant="ghost"
size="sm"
onClick={onRequeue}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
className="p-1.5 sm:p-2"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
@@ -987,72 +1007,72 @@ export function QueuePage() {
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3 lg: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" />
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0">
<Play className="w-4 h-4 sm:w-5 sm: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">{t('queue.summary.printing')}</p>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{activeItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.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" />
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center shrink-0">
<Clock className="w-4 h-4 sm:w-5 sm: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">{t('queue.summary.queued')}</p>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{pendingItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.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" />
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center shrink-0">
<Timer className="w-4 h-4 sm:w-5 sm: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">{t('queue.summary.totalTime')}</p>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{formatDuration(totalQueueTime)}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalTime')}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Weight className="w-5 h-5 text-purple-500" />
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Weight className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
</div>
<div>
<p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
<p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{formatWeight(totalWeight)}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalWeight')}</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" />
<Card className="col-span-2 sm:col-span-1 bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-gray-500/20 flex items-center justify-center shrink-0">
<CheckCircle className="w-4 h-4 sm:w-5 sm: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">{t('queue.summary.history')}</p>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{historyItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.history')}</p>
</div>
</div>
</CardContent>
@@ -1060,9 +1080,9 @@ export function QueuePage() {
</div>
{/* Filters */}
<div className="flex items-center gap-4 mb-6">
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
<select
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"
className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
onChange={(e) => {
const val = e.target.value;
@@ -1079,7 +1099,7 @@ export function QueuePage() {
</select>
<select
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"
className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
@@ -1094,7 +1114,7 @@ export function QueuePage() {
{uniqueLocations.length > 0 && (
<select
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"
className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
value={filterLocation}
onChange={(e) => setFilterLocation(e.target.value)}
>
@@ -1105,10 +1125,11 @@ export function QueuePage() {
</select>
)}
<div className="flex-1" />
<div className="hidden sm:block flex-1" />
{historyItems.length > 0 && (
<Button
className="w-full sm:w-auto"
variant="secondary"
size="sm"
onClick={() => setShowClearHistoryConfirm(true)}
@@ -1132,15 +1153,15 @@ export function QueuePage() {
</p>
</Card>
) : (
<div className="space-y-8">
<div className="space-y-6 sm:space-y-8">
{/* Active Prints */}
{activeItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
{t('queue.sections.currentlyPrinting')}
</h2>
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{activeItems.map((item) => (
<SortableQueueItem
key={item.id}
@@ -1165,20 +1186,20 @@ export function QueuePage() {
{/* Pending Queue */}
{pendingItems.length > 0 && (
<div>
<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" />
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
{t('queue.sections.queued')}
<span className="text-sm font-normal text-bambu-gray">
<span className="text-xs sm:text-sm font-normal text-bambu-gray">
({t('queue.itemCount', { count: pendingItems.length })})
</span>
<span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
<span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
{t('queue.dragToReorder')}
</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"
className="px-2 sm:px-3 py-1.5 text-xs sm: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')}
>
@@ -1200,12 +1221,12 @@ export function QueuePage() {
</div>
{/* Bulk action toolbar */}
<div className="flex items-center gap-3 mb-4 p-3 bg-bambu-dark rounded-lg">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg">
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="flex items-center gap-2"
className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
{selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
<CheckSquare className="w-4 h-4 text-bambu-green" />
@@ -1216,31 +1237,31 @@ export function QueuePage() {
</Button>
{selectedItems.length > 0 && (
<>
<span className="text-sm text-bambu-gray">
<span className="text-xs sm:text-sm text-bambu-gray">
{t('queue.bulkEdit.selected', { count: selectedItems.length })}
</span>
<div className="h-4 w-px bg-bambu-dark-tertiary" />
<div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
<Button
variant="ghost"
size="sm"
onClick={() => setShowBulkEditModal(true)}
className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
>
<Pencil className="w-4 h-4" />
{t('queue.bulkEdit.editSelected')}
<Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => bulkCancelMutation.mutate(selectedItems)}
className="flex items-center gap-2 text-red-400 hover:text-red-300"
className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300"
disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
>
<X className="w-4 h-4" />
{t('queue.bulkEdit.cancelSelected')}
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
</Button>
</>
)}
@@ -1255,7 +1276,7 @@ export function QueuePage() {
items={pendingItems.map(i => i.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{pendingItems.map((item, index) => (
<SortableQueueItem
key={item.id}
@@ -1284,17 +1305,17 @@ export function QueuePage() {
{/* History */}
{historyItems.length > 0 && (
<div>
<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" />
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-gray" />
{t('queue.sections.history')}
<span className="text-sm font-normal text-bambu-gray">
<span className="text-xs sm:text-sm font-normal text-bambu-gray">
({t('queue.itemCount', { count: historyItems.length })})
</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"
className="px-2 sm:px-3 py-1.5 text-xs sm: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')}
>
@@ -1313,7 +1334,7 @@ export function QueuePage() {
</Button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{historyItems.slice(0, 20).map((item, index) => (
<SortableQueueItem
key={item.id}