mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
feat(queue): drag-reorder grouped queue items; collapsed batches no longer block
Batches were stuck where they were created. The parent row had no drag
handle and wasn't registered with dnd-kit's SortableContext, so the
only way to move a grouped item was to ungroup, drag, and re-group.
Collapsed batches also acted as unmovable obstacles for adjacent items.
The batch parent now registers under a synthetic "batch-<id>" string
id and carries a GripVertical handle in the header (gated by
queue:reorder). handleDragEnd resolves both endpoints: dragging a
batch moves all its children as one block, dropping onto a batch
anchors at the batch's first child so the group lands immediately
before it. Direction-aware insert uses the first moving id's index
instead of the previously single dragged id, so multi-row drags and
batch drags share the same insert math. Within-batch child reorder
is unchanged. DragOverlay gained a batch ghost showing
"<name> (<N> copies)".
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -164,6 +164,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
||||
- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
|
||||
- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
|
||||
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
|
||||
- **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
|
||||
- Dual external spool support for H2D (Ext-L / Ext-R)
|
||||
- HMS error monitoring with history and clear errors
|
||||
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
|
||||
|
||||
@@ -194,6 +194,7 @@ const FR_COGNATES = [
|
||||
'{{count}} filament', '{{count}} filaments', '{{count}} permissions',
|
||||
'{{count}} downloads', '{{count}} item', '{{count}} selected',
|
||||
'({{count}} item)', 'Provisioning...', 'Pressure Advance',
|
||||
'{{name}} ({{count}} copies)', // FR plural of "copie" is also "copies"
|
||||
'Box label (62 × 29 mm)',
|
||||
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
|
||||
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Gruppierung aufheben',
|
||||
ungroupConfirmTitle: 'Stapel auflösen?',
|
||||
ungroupConfirmMessage: 'Die Einträge bleiben in der Warteschlange, sind aber nicht mehr gruppiert.',
|
||||
dragGroup: 'Gruppe ziehen',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'Warteschlange',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} Einträge',
|
||||
batch: '{{name}} ({{count}} Kopie)',
|
||||
batch_plural: '{{name}} ({{count}} Kopien)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export default {
|
||||
ungroup: 'Ungroup',
|
||||
ungroupConfirmTitle: 'Ungroup batch?',
|
||||
ungroupConfirmMessage: 'The items will stay in the queue but no longer be grouped together.',
|
||||
dragGroup: 'Drag group',
|
||||
},
|
||||
// Tabs
|
||||
tabs: {
|
||||
@@ -1090,6 +1091,8 @@ export default {
|
||||
// Drag ghost label when multi-dragging
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} items',
|
||||
batch: '{{name}} ({{count}} copy)',
|
||||
batch_plural: '{{name}} ({{count}} copies)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Desagrupar',
|
||||
ungroupConfirmTitle: '¿Desagrupar lote?',
|
||||
ungroupConfirmMessage: 'Los elementos permanecerán en la cola pero ya no estarán agrupados.',
|
||||
dragGroup: 'Arrastrar grupo',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'Cola',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} elementos',
|
||||
batch: '{{name}} ({{count}} copia)',
|
||||
batch_plural: '{{name}} ({{count}} copias)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Dégrouper',
|
||||
ungroupConfirmTitle: 'Dégrouper le lot ?',
|
||||
ungroupConfirmMessage: 'Les éléments resteront dans la file mais ne seront plus groupés.',
|
||||
dragGroup: 'Faire glisser le groupe',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'File',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} éléments',
|
||||
batch: '{{name}} ({{count}} copie)',
|
||||
batch_plural: '{{name}} ({{count}} copies)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Separa',
|
||||
ungroupConfirmTitle: 'Separare il lotto?',
|
||||
ungroupConfirmMessage: 'Gli elementi resteranno in coda ma non saranno più raggruppati.',
|
||||
dragGroup: 'Trascina gruppo',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'Coda',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} elementi',
|
||||
batch: '{{name}} ({{count}} copia)',
|
||||
batch_plural: '{{name}} ({{count}} copie)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1062,6 +1062,7 @@ export default {
|
||||
ungroup: 'グループ解除',
|
||||
ungroupConfirmTitle: 'バッチのグループを解除しますか?',
|
||||
ungroupConfirmMessage: 'アイテムはキューに残りますが、グループ化されなくなります。',
|
||||
dragGroup: 'グループをドラッグ',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'キュー',
|
||||
@@ -1079,6 +1080,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}}件',
|
||||
batch: '{{name}}({{count}}部)',
|
||||
batch_plural: '{{name}}({{count}}部)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1007,6 +1007,7 @@ export default {
|
||||
ungroup: '그룹 해제',
|
||||
ungroupConfirmTitle: '배치 그룹을 해제하시겠습니까?',
|
||||
ungroupConfirmMessage: '항목은 큐에 남아 있지만 더 이상 함께 그룹화되지 않습니다.',
|
||||
dragGroup: '그룹 드래그',
|
||||
},
|
||||
tabs: {
|
||||
queue: '큐',
|
||||
@@ -1024,6 +1025,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}}개 항목',
|
||||
batch: '{{name}} ({{count}}부)',
|
||||
batch_plural: '{{name}} ({{count}}부)',
|
||||
},
|
||||
sections: {
|
||||
currentlyPrinting: '현재 인쇄 중',
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Desagrupar',
|
||||
ungroupConfirmTitle: 'Desagrupar lote?',
|
||||
ungroupConfirmMessage: 'Os itens permanecerão na fila mas não estarão mais agrupados.',
|
||||
dragGroup: 'Arrastar grupo',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'Fila',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} itens',
|
||||
batch: '{{name}} ({{count}} cópia)',
|
||||
batch_plural: '{{name}} ({{count}} cópias)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: 'Gruptan çıkar',
|
||||
ungroupConfirmTitle: 'Yığını gruptan çıkar?',
|
||||
ungroupConfirmMessage: 'Öğeler kuyrukta kalacak ancak artık birlikte gruplanmayacak.',
|
||||
dragGroup: 'Grubu sürükle',
|
||||
},
|
||||
tabs: {
|
||||
queue: 'Kuyruk',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} öğe',
|
||||
batch: '{{name}} ({{count}} kopya)',
|
||||
batch_plural: '{{name}} ({{count}} kopya)',
|
||||
},
|
||||
// Bölümler
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: '取消分组',
|
||||
ungroupConfirmTitle: '取消批次分组?',
|
||||
ungroupConfirmMessage: '项目将保留在队列中,但不再分组在一起。',
|
||||
dragGroup: '拖动批次',
|
||||
},
|
||||
tabs: {
|
||||
queue: '队列',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} 项',
|
||||
batch: '{{name}}({{count}} 份)',
|
||||
batch_plural: '{{name}}({{count}} 份)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ export default {
|
||||
ungroup: '取消分組',
|
||||
ungroupConfirmTitle: '取消批次分組?',
|
||||
ungroupConfirmMessage: '項目將保留在佇列中,但不再分組在一起。',
|
||||
dragGroup: '拖曳批次',
|
||||
},
|
||||
tabs: {
|
||||
queue: '佇列',
|
||||
@@ -1080,6 +1081,8 @@ export default {
|
||||
},
|
||||
dragGhost: {
|
||||
multiCount: '{{count}} 項',
|
||||
batch: '{{name}}({{count}} 份)',
|
||||
batch_plural: '{{name}}({{count}} 份)',
|
||||
},
|
||||
// Sections
|
||||
sections: {
|
||||
|
||||
@@ -771,22 +771,20 @@ interface QueueRowRenderProps {
|
||||
/** Renders either a single item or a collapsible batch group containing N
|
||||
* sibling items. The batch parent shows aggregate stats; children render
|
||||
* with the existing SortableQueueItem (only draggable inside the batch). */
|
||||
function QueueRowRender({
|
||||
row,
|
||||
collapsed,
|
||||
onToggleBatch,
|
||||
onUngroup,
|
||||
setEditItem,
|
||||
setConfirmAction,
|
||||
startMutation,
|
||||
selectedItems,
|
||||
handleToggleSelect,
|
||||
timeFormat,
|
||||
hasPermission,
|
||||
canModify,
|
||||
t,
|
||||
aggregateForRows,
|
||||
}: QueueRowRenderProps) {
|
||||
function QueueRowRender(props: QueueRowRenderProps) {
|
||||
const {
|
||||
row,
|
||||
setEditItem,
|
||||
setConfirmAction,
|
||||
startMutation,
|
||||
selectedItems,
|
||||
handleToggleSelect,
|
||||
timeFormat,
|
||||
hasPermission,
|
||||
canModify,
|
||||
t,
|
||||
} = props;
|
||||
|
||||
if (row.kind === 'item') {
|
||||
return (
|
||||
<SortableQueueItem
|
||||
@@ -807,22 +805,67 @@ function QueueRowRender({
|
||||
);
|
||||
}
|
||||
|
||||
// Batch group
|
||||
const agg = aggregateForRows([row]);
|
||||
const allChildIds = row.items.map((i) => i.id);
|
||||
return <SortableBatchRow {...props} />;
|
||||
}
|
||||
|
||||
/** Batch parent header registered with dnd-kit so the whole group can be
|
||||
* reordered as one unit. Drag handle lives in the header itself; children
|
||||
* remain individually draggable while expanded for within-batch reorder. */
|
||||
function SortableBatchRow({
|
||||
row,
|
||||
collapsed,
|
||||
onToggleBatch,
|
||||
onUngroup,
|
||||
setEditItem,
|
||||
setConfirmAction,
|
||||
startMutation,
|
||||
selectedItems,
|
||||
handleToggleSelect,
|
||||
timeFormat,
|
||||
hasPermission,
|
||||
canModify,
|
||||
t,
|
||||
aggregateForRows,
|
||||
}: QueueRowRenderProps) {
|
||||
// Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
|
||||
// narrow up-front so the hook below can reference batchId unconditionally.
|
||||
const batchRow = row as Extract<QueueRow, { kind: 'batch' }>;
|
||||
const canReorder = hasPermission('queue:reorder');
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const agg = aggregateForRows([batchRow]);
|
||||
const allChildIds = batchRow.items.map((i) => i.id);
|
||||
const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id));
|
||||
// Status rollup: worst-of-children (failed > printing > pending).
|
||||
const childStatuses = new Set(row.items.map((i) => i.status));
|
||||
const childStatuses = new Set(batchRow.items.map((i) => i.status));
|
||||
// We never put non-pending into a batch grouping but render defensively.
|
||||
const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed')
|
||||
? 'failed'
|
||||
: childStatuses.has('printing')
|
||||
? 'printing'
|
||||
: 'pending';
|
||||
const pendingChildren = row.items.filter((i) => i.status === 'pending').length;
|
||||
const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden ${
|
||||
isDragging ? 'opacity-50 scale-[1.01] shadow-xl z-50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Parent header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
|
||||
<button
|
||||
@@ -845,6 +888,16 @@ function QueueRowRender({
|
||||
>
|
||||
{allSelected && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
{canReorder && (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
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"
|
||||
title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-bambu-gray" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleBatch}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
|
||||
@@ -865,7 +918,7 @@ function QueueRowRender({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-sm sm:text-base text-white font-medium truncate">{row.batchName}</p>
|
||||
<p className="text-sm sm:text-base text-white font-medium truncate">{batchRow.batchName}</p>
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-500/15 text-cyan-300 rounded border border-cyan-500/30">
|
||||
{t('queue.batch.label', { count: agg.count })}
|
||||
</span>
|
||||
@@ -909,7 +962,7 @@ function QueueRowRender({
|
||||
{/* Children (only when expanded) */}
|
||||
{!collapsed && (
|
||||
<div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
|
||||
{row.items.map((child) => (
|
||||
{batchRow.items.map((child) => (
|
||||
<SortableQueueItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
@@ -1196,8 +1249,9 @@ export function QueuePage() {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
// Multi-drag bookkeeping for DragOverlay.
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
// Multi-drag bookkeeping for DragOverlay. Numeric for single items, string
|
||||
// `batch-<id>` when a whole group is being dragged.
|
||||
const [activeDragId, setActiveDragId] = useState<number | string | null>(null);
|
||||
// "Group as batch" modal.
|
||||
const [groupBatchModal, setGroupBatchModal] = useState(false);
|
||||
// Ungroup confirm.
|
||||
@@ -1582,7 +1636,8 @@ export function QueuePage() {
|
||||
}, [pendingItems]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveDragId(typeof event.active.id === 'number' ? event.active.id : null);
|
||||
const id = event.active.id;
|
||||
setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -1590,29 +1645,49 @@ export function QueuePage() {
|
||||
setActiveDragId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// Multi-drag: when the dragged row is part of a multi-selection, move
|
||||
// all selected items as a contiguous block, preserving their original
|
||||
// relative order. Otherwise, single-row drag (today's behavior).
|
||||
const draggedId = active.id as number;
|
||||
const movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
|
||||
? selectedItems.slice().sort((a, b) => {
|
||||
const ai = pendingItems.findIndex((i) => i.id === a);
|
||||
const bi = pendingItems.findIndex((i) => i.id === b);
|
||||
return ai - bi;
|
||||
})
|
||||
: [draggedId];
|
||||
// Resolve dragged source → movingIds (preserving order from pendingItems).
|
||||
// - `batch-<id>`: every child of that batch, in their current order
|
||||
// - selected + dragged is one of them: contiguous multi-drag block
|
||||
// - otherwise: single row
|
||||
let movingIds: number[];
|
||||
const activeId = active.id;
|
||||
if (typeof activeId === 'string' && activeId.startsWith('batch-')) {
|
||||
const batchId = Number(activeId.slice('batch-'.length));
|
||||
movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id);
|
||||
} else {
|
||||
const draggedId = activeId as number;
|
||||
movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
|
||||
? selectedItems.slice().sort((a, b) => {
|
||||
const ai = pendingItems.findIndex((i) => i.id === a);
|
||||
const bi = pendingItems.findIndex((i) => i.id === b);
|
||||
return ai - bi;
|
||||
})
|
||||
: [draggedId];
|
||||
}
|
||||
if (movingIds.length === 0) return;
|
||||
|
||||
const overIndex = pendingItems.findIndex(i => i.id === over.id);
|
||||
// Resolve drop target → index inside pendingItems. A `batch-<id>` drop
|
||||
// target anchors at the batch's first child, so dropping above another
|
||||
// batch lands the moving block immediately before it.
|
||||
let overIndex: number;
|
||||
const overId = over.id;
|
||||
if (typeof overId === 'string' && overId.startsWith('batch-')) {
|
||||
const overBatchId = Number(overId.slice('batch-'.length));
|
||||
overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId);
|
||||
} else {
|
||||
overIndex = pendingItems.findIndex((i) => i.id === overId);
|
||||
}
|
||||
if (overIndex === -1) return;
|
||||
|
||||
// Remove the moving items, then re-insert at overIndex (adjusted).
|
||||
const overAnchor = pendingItems[overIndex];
|
||||
const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
|
||||
let insertAt = remaining.findIndex((i) => i.id === over.id);
|
||||
let insertAt = remaining.findIndex((i) => i.id === overAnchor.id);
|
||||
if (insertAt === -1) insertAt = overIndex;
|
||||
// If dragging downward across the drop target, insert AFTER it; upward
|
||||
// = before. dnd-kit's `over` is the row under the pointer, not the gap.
|
||||
const draggedOriginalIndex = pendingItems.findIndex((i) => i.id === draggedId);
|
||||
if (draggedOriginalIndex < overIndex) insertAt += 1;
|
||||
const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]);
|
||||
if (firstMovingIndex < overIndex) insertAt += 1;
|
||||
const reordered = [
|
||||
...remaining.slice(0, insertAt),
|
||||
...movingIds
|
||||
@@ -1653,18 +1728,21 @@ export function QueuePage() {
|
||||
return rows;
|
||||
}, [pendingItems, t]);
|
||||
|
||||
// SortableContext ID list. Only include IDs whose DOM node actually
|
||||
// renders — collapsed batch children are detached from the DOM, so
|
||||
// registering them with dnd-kit confuses the collision resolver when
|
||||
// dragging past a collapsed batch row. v1 batch parent isn't itself
|
||||
// draggable (within-batch reorder only); collapsed batches just act as
|
||||
// unmovable obstacles.
|
||||
const sortableIds = useMemo<number[]>(() => {
|
||||
const ids: number[] = [];
|
||||
// SortableContext ID list.
|
||||
// - Standalone pending items: their numeric id.
|
||||
// - Batch parents: the synthetic `batch-<id>` string, always present so the
|
||||
// group itself is draggable and acts as a drop target whether collapsed
|
||||
// or expanded.
|
||||
// - Expanded batch children: their numeric id, so within-batch reorder
|
||||
// keeps working. Collapsed children are detached from the DOM and
|
||||
// intentionally omitted to keep dnd-kit's collision resolver clean.
|
||||
const sortableIds = useMemo<(number | string)[]>(() => {
|
||||
const ids: (number | string)[] = [];
|
||||
for (const row of groupedRows) {
|
||||
if (row.kind === 'item') {
|
||||
ids.push(row.item.id);
|
||||
} else {
|
||||
ids.push(`batch-${row.batchId}`);
|
||||
const collapsed = batchCollapsed[row.batchId] ?? true;
|
||||
if (!collapsed) {
|
||||
for (const child of row.items) ids.push(child.id);
|
||||
@@ -2166,14 +2244,40 @@ export function QueuePage() {
|
||||
)}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragId !== null && selectedItems.includes(activeDragId) && selectedItems.length > 1 ? (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
|
||||
<Package className="w-4 h-4 text-cyan-300" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
if (activeDragId === null) return null;
|
||||
// Batch drag — show the group ghost with copy count.
|
||||
if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) {
|
||||
const batchId = Number(activeDragId.slice('batch-'.length));
|
||||
const siblings = pendingItems.filter((i) => i.batch_id === batchId);
|
||||
if (siblings.length === 0) return null;
|
||||
const name = siblings[0].batch_name || t('queue.batch.defaultName');
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
|
||||
<Package className="w-4 h-4 text-cyan-300" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{t('queue.dragGhost.batch', {
|
||||
defaultValue: '{{name}} ({{count}} copies)',
|
||||
name,
|
||||
count: siblings.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Multi-row drag — show the N-item ghost.
|
||||
if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
|
||||
<Package className="w-4 h-4 text-cyan-300" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user