mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Feat: Download Queue Move to Top/Bottom + Tooltip (#38)
This commit is contained in:
@@ -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;
|
||||||
|
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||||
onClearSelect: () => 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,
|
||||||
|
|||||||
@@ -13,17 +13,19 @@
|
|||||||
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) => void;
|
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||||
onClearSelect: () => void;
|
onClearSelect: () => void;
|
||||||
onBatchRemove: () => void;
|
onBatchRemove: () => void;
|
||||||
onBatchRetry: () => void;
|
onBatchRetry: () => void;
|
||||||
onBatchReorder: (dir: "up" | "down") => 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user