Feat: Extension of Download Features, Batch Select, Error/Retry (#38)

This commit is contained in:
Youwes09
2026-04-21 10:57:29 -05:00
parent 2d3a4d0e57
commit 86c78689df
9 changed files with 626 additions and 68 deletions
@@ -1,51 +1,224 @@
<script lang="ts">
import { CircleNotch, X } from "phosphor-svelte";
import { CircleNotch, ArrowUp, ArrowDown, 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";
import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
onRemove: (chapterId: number) => void;
item: DownloadQueueItem;
index: number;
isActive: boolean;
isFirst: boolean;
isLast: boolean;
isRemoving: boolean;
isSelected: boolean;
selectedCount: number;
selectedErrorCount: number;
batchWorking: boolean;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void;
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
onBatchRemove: () => void;
onBatchRetry: () => void;
onBatchReorder: (dir: "up" | "down") => void;
onClearSelect: () => void;
}
const { item, isActive, isRemoving, onRemove }: Props = $props();
const {
item, index, isActive, isFirst, isLast, isRemoving,
isSelected, selectedCount, selectedErrorCount, batchWorking,
onRemove, onRetry, onReorder, onSelect,
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect,
}: Props = $props();
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100));
let menuX = $state(0);
let menuY = $state(0);
let menuOpen = $state(false);
function openMenu(e: MouseEvent) {
e.preventDefault();
menuX = e.clientX;
menuY = e.clientY;
menuOpen = true;
}
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let touchMoved = false;
function onTouchStart(e: TouchEvent) {
touchMoved = false;
const touch = e.touches[0];
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (touchMoved) return;
if (selectedCount === 0) {
onSelect(item.chapter.id, { shiftKey: false, ctrlKey: false, metaKey: false });
} else {
menuX = touch.clientX;
menuY = touch.clientY;
menuOpen = true;
}
}, 500);
}
function onTouchMove() {
touchMoved = true;
cancelLongPress();
}
function cancelLongPress() {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
const menuItems = $derived.by<MenuEntry[]>(() => {
const inBatch = isSelected && selectedCount > 1;
const entries: MenuEntry[] = [];
if (inBatch) {
entries.push({
label: `Move up (${selectedCount})`,
icon: ArrowUp,
onClick: () => onBatchReorder("up"),
disabled: batchWorking,
});
entries.push({
label: `Move down (${selectedCount})`,
icon: ArrowDown,
onClick: () => onBatchReorder("down"),
disabled: batchWorking,
});
entries.push({ separator: true });
if (selectedErrorCount > 0) {
entries.push({
label: `Retry errors (${selectedErrorCount})`,
icon: ArrowClockwise,
onClick: onBatchRetry,
disabled: batchWorking,
});
}
entries.push({
label: `Remove selected (${selectedCount})`,
icon: X,
onClick: onBatchRemove,
danger: true,
disabled: batchWorking,
});
entries.push({ separator: true });
entries.push({ label: "Deselect all", onClick: onClearSelect });
} else {
if (isError) {
entries.push({
label: "Retry",
icon: ArrowClockwise,
onClick: () => onRetry(item.chapter.id),
disabled: isRemoving,
});
entries.push({ separator: true });
}
entries.push({
label: "Move up",
icon: ArrowUp,
onClick: () => onReorder(item.chapter.id, "up"),
disabled: isFirst || isActive,
});
entries.push({
label: "Move down",
icon: ArrowDown,
onClick: () => onReorder(item.chapter.id, "down"),
disabled: isLast || isActive,
});
entries.push({ separator: true });
entries.push({
label: "Remove",
icon: X,
onClick: () => onRemove(item.chapter.id),
danger: true,
disabled: isRemoving || isActive,
});
}
return entries;
});
</script>
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
<div
class="row"
class:row-active={isActive}
class:row-error={isError}
class:row-selected={isSelected}
class:row-removing={isRemoving}
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
oncontextmenu={openMenu}
ontouchstart={onTouchStart}
ontouchend={cancelLongPress}
ontouchmove={onTouchMove}
>
{#if manga?.thumbnailUrl}
<div class="thumb">
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="thumb-img" />
</div>
{/if}
<div class="info">
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
<span class="chapter-name">{item.chapter.name}</span>
{#if pages > 0}
<span class="pages-label">{isActive ? `${prog.done} / ${prog.total} pages` : `${prog.total} pages`}</span>
{/if}
{#if isActive}
<div class="progress-wrap">
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
<div class="progress-row">
<div class="progress-wrap">
<div class="progress-bar" class:progress-error={isError} style="width:{pct}%"></div>
</div>
<span class="pages-label">
{#if isActive}
{prog.done}/{prog.total}
{:else if isError}
failed · {item.tries} {item.tries === 1 ? "try" : "tries"}
{:else}
{prog.total}p
{/if}
</span>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" onclick={() => onRemove(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
<span class="state-label" class:state-error={isError}>{item.state}</span>
<div class="actions">
{#if isError}
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
</button>
{/if}
{#if !isActive}
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
<ArrowUp size={11} weight="light" />
</button>
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
<ArrowDown size={11} weight="light" />
</button>
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</div>
</div>
</div>
{#if menuOpen}
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
{/if}
<style>
.row {
display: flex;
@@ -55,9 +228,16 @@
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: border-color var(--t-fast), opacity var(--t-base);
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.row.row-active { border-color: var(--accent-dim); }
.row.row-active { border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
.row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb {
@@ -75,10 +255,11 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.manga-title {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
@@ -87,6 +268,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-name {
font-size: var(--text-xs);
color: var(--text-muted);
@@ -94,25 +276,38 @@
overflow: hidden;
text-overflow: ellipsis;
}
.pages-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
.progress-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.progress-wrap {
flex: 1;
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 4px;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
opacity: 0.5;
}
.row-active .progress-bar { opacity: 1; }
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
.pages-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
white-space: nowrap;
}
.row-right {
@@ -122,6 +317,7 @@
gap: var(--sp-1);
flex-shrink: 0;
}
.state-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
@@ -129,8 +325,15 @@
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
.remove-btn {
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
@@ -138,8 +341,14 @@
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base), background var(--t-base);
}
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.remove-btn:disabled { opacity: 0.5; cursor: default; }
</style>
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.action-btn:disabled { opacity: 0.25; cursor: default; }
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); }
</style>