mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: Download Queue Move to Top/Bottom + Tooltip (#38)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<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 ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
@@ -20,18 +20,20 @@
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => 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;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
onClearSelect: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
item, index, isActive, isFirst, isLast, isRemoving,
|
||||
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
||||
onRemove, onRetry, onReorder, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const manga = $derived(item.chapter.manga);
|
||||
@@ -87,6 +89,19 @@
|
||||
const entries: MenuEntry[] = [];
|
||||
|
||||
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({
|
||||
label: `Move up (${selectedCount})`,
|
||||
icon: ArrowUp,
|
||||
@@ -127,6 +142,19 @@
|
||||
});
|
||||
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({
|
||||
label: "Move up",
|
||||
icon: ArrowUp,
|
||||
|
||||
@@ -4,26 +4,28 @@
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected, batchWorking,
|
||||
onRemove, onRetry, onReorder, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
|
||||
}: Props = $props();
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
@@ -39,6 +41,22 @@
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<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)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
@@ -54,11 +72,13 @@
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
{onClearSelect}
|
||||
{onBatchRemove}
|
||||
{onBatchRetry}
|
||||
{onBatchReorder}
|
||||
{onBatchReorderEdge}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -71,6 +91,59 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -121,11 +121,13 @@
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onSelect={handleSelect}
|
||||
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
|
||||
onBatchRemove={() => downloadStore.dequeueSelected()}
|
||||
onBatchRetry={() => downloadStore.retrySelected()}
|
||||
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
|
||||
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,16 @@ export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): nu
|
||||
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 {
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
|
||||
@@ -231,6 +231,56 @@ class DownloadStore {
|
||||
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]); }
|
||||
toggleSelect(chapterId: number) {
|
||||
const next = new Set(this.selected);
|
||||
|
||||
Reference in New Issue
Block a user