|
|
@@ -50,6 +50,7 @@ import {
|
|
|
Weight,
|
|
|
ChevronDown,
|
|
|
ChevronRight,
|
|
|
+ ChevronUp,
|
|
|
List,
|
|
|
GanttChart,
|
|
|
Code,
|
|
|
@@ -347,6 +348,8 @@ function SortableQueueItem({
|
|
|
onStop,
|
|
|
onRequeue,
|
|
|
onStart,
|
|
|
+ onMoveUp,
|
|
|
+ onMoveDown,
|
|
|
timeFormat = 'system',
|
|
|
isSelected = false,
|
|
|
onToggleSelect,
|
|
|
@@ -363,6 +366,11 @@ function SortableQueueItem({
|
|
|
onStop: () => void;
|
|
|
onRequeue: () => void;
|
|
|
onStart: () => void;
|
|
|
+ // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button
|
|
|
+ // shown disabled) or reordering isn't available; the desktop drag handle
|
|
|
+ // is unaffected. Move one step among siblings, then persist via reorder.
|
|
|
+ onMoveUp?: () => void;
|
|
|
+ onMoveDown?: () => void;
|
|
|
timeFormat?: TimeFormat;
|
|
|
isSelected?: boolean;
|
|
|
onToggleSelect?: () => void;
|
|
|
@@ -451,6 +459,37 @@ function SortableQueueItem({
|
|
|
)}
|
|
|
|
|
|
<div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
|
|
|
+ {/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on
|
|
|
+ phones and touch-drag is unreliable there, so pending rows get
|
|
|
+ tap-to-move up/down controls instead. Shown only below `sm`. */}
|
|
|
+ {isPending && (onMoveUp || onMoveDown) && (
|
|
|
+ <div
|
|
|
+ className="flex sm:hidden flex-col shrink-0 -my-1"
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onMoveUp}
|
|
|
+ disabled={!onMoveUp}
|
|
|
+ title={t('queue.moveUp')}
|
|
|
+ aria-label={t('queue.moveUp')}
|
|
|
+ className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
|
|
|
+ >
|
|
|
+ <ChevronUp className="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onMoveDown}
|
|
|
+ disabled={!onMoveDown}
|
|
|
+ title={t('queue.moveDown')}
|
|
|
+ aria-label={t('queue.moveDown')}
|
|
|
+ className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
|
|
|
+ >
|
|
|
+ <ChevronDown className="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* Mobile selection indicator — left accent bar only, no tick */}
|
|
|
|
|
|
{/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
|
|
|
@@ -475,7 +514,7 @@ function SortableQueueItem({
|
|
|
<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"
|
|
|
+ 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-none shrink-0"
|
|
|
>
|
|
|
<GripVertical className="w-4 h-4 text-bambu-gray" />
|
|
|
</div>
|
|
|
@@ -806,6 +845,13 @@ interface QueueRowRenderProps {
|
|
|
canModify: (resource: any, action: any, createdById?: number | null) => boolean;
|
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
|
aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
|
|
|
+ // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
|
|
|
+ // (single item or batch) one step among its siblings; onMoveBlock is the
|
|
|
+ // low-level primitive SortableBatchRow uses to move a child within the
|
|
|
+ // batch. All undefined when reordering isn't available (non-manual sort).
|
|
|
+ onMoveUp?: () => void;
|
|
|
+ onMoveDown?: () => void;
|
|
|
+ onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void;
|
|
|
}
|
|
|
|
|
|
/** Renders either a single item or a collapsible batch group containing N
|
|
|
@@ -823,6 +869,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
|
|
|
hasPermission,
|
|
|
canModify,
|
|
|
t,
|
|
|
+ onMoveUp,
|
|
|
+ onMoveDown,
|
|
|
} = props;
|
|
|
|
|
|
if (row.kind === 'item') {
|
|
|
@@ -835,6 +883,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
|
|
|
onStop={() => {}}
|
|
|
onRequeue={() => {}}
|
|
|
onStart={() => startMutation.mutate({ id: row.item.id })}
|
|
|
+ onMoveUp={onMoveUp}
|
|
|
+ onMoveDown={onMoveDown}
|
|
|
timeFormat={timeFormat}
|
|
|
isSelected={selectedItems.includes(row.item.id)}
|
|
|
onToggleSelect={() => handleToggleSelect(row.item.id)}
|
|
|
@@ -866,6 +916,9 @@ function SortableBatchRow({
|
|
|
canModify,
|
|
|
t,
|
|
|
aggregateForRows,
|
|
|
+ onMoveUp,
|
|
|
+ onMoveDown,
|
|
|
+ onMoveBlock,
|
|
|
}: QueueRowRenderProps) {
|
|
|
// Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
|
|
|
// narrow up-front so the hook below can reference batchId unconditionally.
|
|
|
@@ -908,6 +961,32 @@ function SortableBatchRow({
|
|
|
>
|
|
|
{/* Parent header */}
|
|
|
<div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
|
|
|
+ {/* Mobile reorder arrows for the whole group (#2667), mirroring the
|
|
|
+ desktop drag handle which is hidden on phones. */}
|
|
|
+ {canReorder && (onMoveUp || onMoveDown) && (
|
|
|
+ <div className="flex sm:hidden flex-col shrink-0 -my-1">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onMoveUp}
|
|
|
+ disabled={!onMoveUp}
|
|
|
+ title={t('queue.moveUp')}
|
|
|
+ aria-label={t('queue.moveUp')}
|
|
|
+ className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
|
|
|
+ >
|
|
|
+ <ChevronUp className="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onMoveDown}
|
|
|
+ disabled={!onMoveDown}
|
|
|
+ title={t('queue.moveDown')}
|
|
|
+ aria-label={t('queue.moveDown')}
|
|
|
+ className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
|
|
|
+ >
|
|
|
+ <ChevronDown className="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
<button
|
|
|
onClick={(e) => {
|
|
|
e.stopPropagation();
|
|
|
@@ -932,7 +1011,7 @@ function SortableBatchRow({
|
|
|
<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"
|
|
|
+ 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-none shrink-0"
|
|
|
title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
|
|
|
>
|
|
|
<GripVertical className="w-4 h-4 text-bambu-gray" />
|
|
|
@@ -1002,7 +1081,7 @@ function SortableBatchRow({
|
|
|
{/* Children (only when expanded) */}
|
|
|
{!collapsed && (
|
|
|
<div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
|
|
|
- {batchRow.items.map((child) => (
|
|
|
+ {batchRow.items.map((child, ci) => (
|
|
|
<SortableQueueItem
|
|
|
key={child.id}
|
|
|
item={child}
|
|
|
@@ -1012,6 +1091,16 @@ function SortableBatchRow({
|
|
|
onStop={() => {}}
|
|
|
onRequeue={() => {}}
|
|
|
onStart={() => startMutation.mutate({ id: child.id })}
|
|
|
+ onMoveUp={
|
|
|
+ onMoveBlock && ci > 0
|
|
|
+ ? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false)
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ onMoveDown={
|
|
|
+ onMoveBlock && ci < batchRow.items.length - 1
|
|
|
+ ? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true)
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
timeFormat={timeFormat}
|
|
|
isSelected={selectedItems.includes(child.id)}
|
|
|
onToggleSelect={() => handleToggleSelect(child.id)}
|
|
|
@@ -1033,6 +1122,8 @@ type HistoryRow =
|
|
|
interface HistorySectionProps {
|
|
|
items: PrintQueueItem[];
|
|
|
collapsed: boolean;
|
|
|
+ visibleCount: number;
|
|
|
+ onShowMore: () => void;
|
|
|
sortBy: 'date' | 'name' | 'printer';
|
|
|
sortAsc: boolean;
|
|
|
onSortByChange: (v: 'date' | 'name' | 'printer') => void;
|
|
|
@@ -1051,6 +1142,8 @@ interface HistorySectionProps {
|
|
|
|
|
|
function HistorySection({
|
|
|
items,
|
|
|
+ visibleCount,
|
|
|
+ onShowMore,
|
|
|
sortBy,
|
|
|
sortAsc,
|
|
|
onSortByChange,
|
|
|
@@ -1079,7 +1172,7 @@ function HistorySection({
|
|
|
// position from the parent's sort selector.
|
|
|
const rows: HistoryRow[] = [];
|
|
|
const seenBatches = new Set<number>();
|
|
|
- for (const item of items.slice(0, 50)) {
|
|
|
+ for (const item of items.slice(0, visibleCount)) {
|
|
|
if (item.batch_id != null) {
|
|
|
if (seenBatches.has(item.batch_id)) continue;
|
|
|
seenBatches.add(item.batch_id);
|
|
|
@@ -1227,10 +1320,25 @@ function HistorySection({
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
+ {items.length > visibleCount && (
|
|
|
+ <div className="mt-4 flex flex-col items-center gap-2">
|
|
|
+ <Button variant="secondary" size="sm" onClick={onShowMore}>
|
|
|
+ {t('queue.history.showMore')}
|
|
|
+ </Button>
|
|
|
+ <span className="text-xs text-bambu-gray">
|
|
|
+ {t('queue.history.showingCount', {
|
|
|
+ shown: Math.min(visibleCount, items.length),
|
|
|
+ total: items.length,
|
|
|
+ })}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+const HISTORY_PAGE_SIZE = 50;
|
|
|
+
|
|
|
export function QueuePage() {
|
|
|
const { t } = useTranslation();
|
|
|
const queryClient = useQueryClient();
|
|
|
@@ -1263,6 +1371,10 @@ export function QueuePage() {
|
|
|
const saved = localStorage.getItem('queue.historySortAsc');
|
|
|
return saved !== null ? saved === 'true' : false;
|
|
|
});
|
|
|
+ // #2682: History renders progressively — start at one page, grow on demand.
|
|
|
+ // Reset happens only on a deliberate re-sort / filter change (below), NOT on
|
|
|
+ // the periodic queue poll, so an expanded view doesn't snap back mid-scroll.
|
|
|
+ const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
|
|
|
const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
|
|
|
const saved = localStorage.getItem('queue.pendingSortBy');
|
|
|
return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
|
|
|
@@ -1320,6 +1432,13 @@ export function QueuePage() {
|
|
|
localStorage.setItem('queue.historySortAsc', String(historySortAsc));
|
|
|
}, [historySortAsc]);
|
|
|
|
|
|
+ // Collapse History back to a single page when the user re-sorts or changes
|
|
|
+ // the location filter (deliberate view changes). Intentionally excludes the
|
|
|
+ // queue poll so periodic refetches keep the expanded count.
|
|
|
+ useEffect(() => {
|
|
|
+ setHistoryVisibleCount(HISTORY_PAGE_SIZE);
|
|
|
+ }, [historySortBy, historySortAsc, filterLocation]);
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
localStorage.setItem('queue.pendingSortBy', pendingSortBy);
|
|
|
}, [pendingSortBy]);
|
|
|
@@ -1797,6 +1916,68 @@ export function QueuePage() {
|
|
|
return rows;
|
|
|
}, [pendingItems, t]);
|
|
|
|
|
|
+ // Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on
|
|
|
+ // phones and touch-drag is unreliable, so pending rows get up/down arrows.
|
|
|
+ // Reordering only has a defined meaning in the manual "position" sort with
|
|
|
+ // SJF off — any other sort re-orders the list itself, so we don't offer it.
|
|
|
+ const canReorderManually =
|
|
|
+ hasPermission('queue:reorder') &&
|
|
|
+ pendingSortBy === 'position' &&
|
|
|
+ !settings?.queue_shortest_first;
|
|
|
+
|
|
|
+ const rowItemIds = (row: QueueRow): number[] =>
|
|
|
+ row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id);
|
|
|
+
|
|
|
+ // Move a block of items to sit immediately before (or after) an anchor item
|
|
|
+ // in the global pending order, then persist — the same remove-and-reinsert
|
|
|
+ // shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item
|
|
|
+ // id keeps it correct in the printer-grouped layout, where a bucket's rows
|
|
|
+ // aren't contiguous in the global order.
|
|
|
+ const moveBlockRelativeTo = (
|
|
|
+ movingIds: number[],
|
|
|
+ anchorId: number,
|
|
|
+ placeAfter: boolean,
|
|
|
+ ) => {
|
|
|
+ const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
|
|
|
+ let insertAt = remaining.findIndex((i) => i.id === anchorId);
|
|
|
+ if (insertAt === -1) return;
|
|
|
+ if (placeAfter) insertAt += 1;
|
|
|
+ const movingItems = movingIds
|
|
|
+ .map((id) => pendingItems.find((i) => i.id === id))
|
|
|
+ .filter((x): x is PrintQueueItem => !!x);
|
|
|
+ const reordered = [
|
|
|
+ ...remaining.slice(0, insertAt),
|
|
|
+ ...movingItems,
|
|
|
+ ...remaining.slice(insertAt),
|
|
|
+ ];
|
|
|
+ reorderMutation.mutate(
|
|
|
+ reordered.map((item, index) => ({ id: item.id, position: index + 1 })),
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ // Build up/down thunks for the row at `idx` within its displayed sibling
|
|
|
+ // list (the flat list, or a single printer bucket). Undefined at a boundary
|
|
|
+ // (button rendered disabled) or when manual reorder isn't available.
|
|
|
+ const rowMovers = (
|
|
|
+ rows: QueueRow[],
|
|
|
+ idx: number,
|
|
|
+ ): { onMoveUp?: () => void; onMoveDown?: () => void } => {
|
|
|
+ if (!canReorderManually) return {};
|
|
|
+ const moving = rowItemIds(rows[idx]);
|
|
|
+ const onMoveUp =
|
|
|
+ idx > 0
|
|
|
+ ? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false)
|
|
|
+ : undefined;
|
|
|
+ const onMoveDown =
|
|
|
+ idx < rows.length - 1
|
|
|
+ ? () => {
|
|
|
+ const next = rowItemIds(rows[idx + 1]);
|
|
|
+ moveBlockRelativeTo(moving, next[next.length - 1], true);
|
|
|
+ }
|
|
|
+ : undefined;
|
|
|
+ return { onMoveUp, onMoveDown };
|
|
|
+ };
|
|
|
+
|
|
|
// SortableContext ID list.
|
|
|
// - Standalone pending items: their numeric id.
|
|
|
// - Batch parents: the synthetic `batch-<id>` string, always present so the
|
|
|
@@ -2183,6 +2364,8 @@ export function QueuePage() {
|
|
|
<HistorySection
|
|
|
items={historyItems}
|
|
|
collapsed={false}
|
|
|
+ visibleCount={historyVisibleCount}
|
|
|
+ onShowMore={() => setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)}
|
|
|
sortBy={historySortBy}
|
|
|
sortAsc={historySortAsc}
|
|
|
onSortByChange={setHistorySortBy}
|
|
|
@@ -2335,7 +2518,7 @@ export function QueuePage() {
|
|
|
>
|
|
|
{activeLayout === 'position' ? (
|
|
|
<div className="space-y-2 sm:space-y-3">
|
|
|
- {groupedRows.map((row) => (
|
|
|
+ {groupedRows.map((row, idx) => (
|
|
|
<QueueRowRender
|
|
|
key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
|
|
|
row={row}
|
|
|
@@ -2352,6 +2535,8 @@ export function QueuePage() {
|
|
|
canModify={canModify}
|
|
|
t={t}
|
|
|
aggregateForRows={aggregateForRows}
|
|
|
+ {...rowMovers(groupedRows, idx)}
|
|
|
+ onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
@@ -2371,7 +2556,7 @@ export function QueuePage() {
|
|
|
</span>
|
|
|
</div>
|
|
|
<div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
|
|
|
- {bucket.rows.map((row) => (
|
|
|
+ {bucket.rows.map((row, idx) => (
|
|
|
<QueueRowRender
|
|
|
key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
|
|
|
row={row}
|
|
|
@@ -2388,6 +2573,8 @@ export function QueuePage() {
|
|
|
canModify={canModify}
|
|
|
t={t}
|
|
|
aggregateForRows={aggregateForRows}
|
|
|
+ {...rowMovers(bucket.rows, idx)}
|
|
|
+ onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|