mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
382 lines
12 KiB
Svelte
382 lines
12 KiB
Svelte
<script lang="ts">
|
|
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";
|
|
import type { DownloadQueueItem } from "@types/index";
|
|
import { pageProgress } from "../lib/downloadQueue";
|
|
|
|
interface Props {
|
|
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;
|
|
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;
|
|
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
|
onClearSelect: () => void;
|
|
}
|
|
|
|
const {
|
|
item, index, isActive, isFirst, isLast, isRemoving,
|
|
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
|
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
|
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
|
|
}: Props = $props();
|
|
|
|
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 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,
|
|
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 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,
|
|
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-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}
|
|
<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" 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;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-3);
|
|
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), 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-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 {
|
|
width: 36px;
|
|
height: 54px;
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
background: var(--bg-overlay);
|
|
flex-shrink: 0;
|
|
border: 1px solid var(--border-dim);
|
|
}
|
|
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
.info {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
.manga-title {
|
|
font-size: var(--text-sm);
|
|
font-weight: var(--weight-medium);
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.chapter-name {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: var(--sp-1);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.state-label {
|
|
font-family: var(--font-ui);
|
|
font-size: var(--text-2xs);
|
|
color: var(--text-faint);
|
|
letter-spacing: var(--tracking-wider);
|
|
text-transform: uppercase;
|
|
}
|
|
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
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);
|
|
}
|
|
.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> |