Feat: Download Queue Move to Top/Bottom + Tooltip (#38)

This commit is contained in:
Youwes09
2026-04-23 20:54:57 -05:00
parent 4e6be5d9f5
commit 634d32f372
5 changed files with 186 additions and 23 deletions
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowUp, ArrowDown, ArrowClockwise, X } from "phosphor-svelte"; import { CircleNotch, ArrowUp, ArrowDown, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte"; import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte"; import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
@@ -20,18 +20,20 @@
onRemove: (chapterId: number) => void; onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void; onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void; onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void; onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
onBatchRemove: () => void; onBatchRemove: () => void;
onBatchRetry: () => void; onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void; onBatchReorder: (dir: "up" | "down") => void;
onClearSelect: () => void; onBatchReorderEdge: (edge: "top" | "bottom") => void;
onClearSelect: () => void;
} }
const { const {
item, index, isActive, isFirst, isLast, isRemoving, item, index, isActive, isFirst, isLast, isRemoving,
isSelected, selectedCount, selectedErrorCount, batchWorking, isSelected, selectedCount, selectedErrorCount, batchWorking,
onRemove, onRetry, onReorder, onSelect, onRemove, onRetry, onReorder, onReorderEdge, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect, onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
}: Props = $props(); }: Props = $props();
const manga = $derived(item.chapter.manga); const manga = $derived(item.chapter.manga);
@@ -87,6 +89,19 @@
const entries: MenuEntry[] = []; const entries: MenuEntry[] = [];
if (inBatch) { if (inBatch) {
entries.push({
label: `Move to top (${selectedCount})`,
icon: ArrowLineUp,
onClick: () => onBatchReorderEdge("top"),
disabled: batchWorking,
});
entries.push({
label: `Move to bottom (${selectedCount})`,
icon: ArrowLineDown,
onClick: () => onBatchReorderEdge("bottom"),
disabled: batchWorking,
});
entries.push({ separator: true });
entries.push({ entries.push({
label: `Move up (${selectedCount})`, label: `Move up (${selectedCount})`,
icon: ArrowUp, icon: ArrowUp,
@@ -127,6 +142,19 @@
}); });
entries.push({ separator: true }); entries.push({ separator: true });
} }
entries.push({
label: "Move to top",
icon: ArrowLineUp,
onClick: () => onReorderEdge(item.chapter.id, "top"),
disabled: isFirst || isActive,
});
entries.push({
label: "Move to bottom",
icon: ArrowLineDown,
onClick: () => onReorderEdge(item.chapter.id, "bottom"),
disabled: isLast || isActive,
});
entries.push({ separator: true });
entries.push({ entries.push({
label: "Move up", label: "Move up",
icon: ArrowUp, icon: ArrowUp,
@@ -4,26 +4,28 @@
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
interface Props { interface Props {
queue: DownloadQueueItem[]; queue: DownloadQueueItem[];
loading: boolean; loading: boolean;
isRunning: boolean; isRunning: boolean;
dequeueing: Set<number>; dequeueing: Set<number>;
selected: Set<number>; selected: Set<number>;
batchWorking: boolean; batchWorking: boolean;
onRemove: (chapterId: number) => void; onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void; onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void; onReorder: (chapterId: number, dir: "up" | "down") => void;
onSelect: (chapterId: number, e: MouseEvent) => void; onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onClearSelect: () => void; onSelect: (chapterId: number, e: MouseEvent) => void;
onBatchRemove: () => void; onClearSelect: () => void;
onBatchRetry: () => void; onBatchRemove: () => void;
onBatchReorder: (dir: "up" | "down") => void; onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void;
onBatchReorderEdge: (edge: "top" | "bottom") => void;
} }
const { const {
queue, loading, isRunning, dequeueing, selected, batchWorking, queue, loading, isRunning, dequeueing, selected, batchWorking,
onRemove, onRetry, onReorder, onSelect, onClearSelect, onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
}: Props = $props(); }: Props = $props();
const selectedErrorCount = $derived( const selectedErrorCount = $derived(
@@ -39,6 +41,22 @@
<div class="empty">Queue is empty.</div> <div class="empty">Queue is empty.</div>
{:else} {:else}
<div class="list"> <div class="list">
<div class="list-header">
<div class="info-wrap">
<button class="info-btn" tabindex="-1" aria-label="Selection help">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<circle cx="6" cy="6" r="5.25" stroke="currentColor" stroke-width="1.5"/>
<rect x="5.25" y="5" width="1.5" height="3.5" rx="0.75" fill="currentColor"/>
<circle cx="6" cy="3.25" r="0.85" fill="currentColor"/>
</svg>
</button>
<div class="info-popover" role="tooltip">
<span>Click to select</span>
<span>Shift+click to range select</span>
<span>Ctrl+click to toggle</span>
</div>
</div>
</div>
{#each queue as item, i (item.chapter.id)} {#each queue as item, i (item.chapter.id)}
<DownloadItem <DownloadItem
{item} {item}
@@ -54,11 +72,13 @@
{onRemove} {onRemove}
{onRetry} {onRetry}
{onReorder} {onReorder}
{onReorderEdge}
{onSelect} {onSelect}
{onClearSelect} {onClearSelect}
{onBatchRemove} {onBatchRemove}
{onBatchRetry} {onBatchRetry}
{onBatchReorder} {onBatchReorder}
{onBatchReorderEdge}
/> />
{/each} {/each}
</div> </div>
@@ -71,6 +91,59 @@
gap: var(--sp-2); gap: var(--sp-2);
} }
.list-header {
display: flex;
justify-content: flex-end;
padding: 0 var(--sp-1);
}
.info-wrap {
position: relative;
display: flex;
align-items: center;
}
.info-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: none;
padding: 0;
cursor: default;
color: var(--text-faint);
border-radius: var(--radius-sm);
transition: color var(--t-base);
}
.info-btn:hover { color: var(--text-muted); }
.info-popover {
position: absolute;
right: 0;
top: calc(100% + 6px);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: var(--sp-2) var(--sp-3);
display: none;
flex-direction: column;
gap: 4px;
white-space: nowrap;
z-index: 50;
pointer-events: none;
}
.info-popover span {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.info-wrap:hover .info-popover { display: flex; }
.empty { .empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -121,11 +121,13 @@
onRemove={(id) => downloadStore.dequeue(id)} onRemove={(id) => downloadStore.dequeue(id)}
onRetry={(id) => downloadStore.retryOne(id)} onRetry={(id) => downloadStore.retryOne(id)}
onReorder={(id, dir) => downloadStore.reorder(id, dir)} onReorder={(id, dir) => downloadStore.reorder(id, dir)}
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
onSelect={handleSelect} onSelect={handleSelect}
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }} onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
onBatchRemove={() => downloadStore.dequeueSelected()} onBatchRemove={() => downloadStore.dequeueSelected()}
onBatchRetry={() => downloadStore.retrySelected()} onBatchRetry={() => downloadStore.retrySelected()}
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)} onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
/> />
</div> </div>
</div> </div>
@@ -55,6 +55,16 @@ export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): nu
return remaining / pagesPerSec; return remaining / pagesPerSec;
} }
export function reorderSelectedToEdge(
queue: DownloadQueueItem[],
selected: Set<number>,
edge: "top" | "bottom",
): DownloadQueueItem[] {
const pinned = queue.filter((i) => selected.has(i.chapter.id));
const rest = queue.filter((i) => !selected.has(i.chapter.id));
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
export function formatEta(seconds: number): string { export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`; if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
@@ -231,6 +231,56 @@ class DownloadStore {
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const pinned = this.queue.filter((i) => this.selected.has(i.chapter.id));
const rest = this.queue.filter((i) => !this.selected.has(i.chapter.id));
const newQueue = edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (const item of [...pinned].reverse()) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: first },
);
}
} else {
for (const item of pinned) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: last },
);
}
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); } selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) { toggleSelect(chapterId: number) {
const next = new Set(this.selected); const next = new Set(this.selected);