Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
@@ -0,0 +1,140 @@
<script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
isSelected: boolean;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
item, isActive, isRemoving, isSelected,
onRemove, onRetry, onSelect,
}: 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));
function rowLongPress(node: HTMLElement) {
return longPress(node, {
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
});
}
</script>
<div
class="row"
class:row-active={isActive}
class:row-error={isError}
class:row-selected={isSelected}
class:row-removing={isRemoving}
role="option"
aria-selected={isSelected}
tabindex="0"
use:rowLongPress
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
>
{#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 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>
<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:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
.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>
@@ -0,0 +1,98 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import DownloadItem from "./DownloadItem.svelte";
import type { DownloadQueueItem } from "@types/index";
interface Props {
queue: DownloadQueueItem[];
loading: boolean;
isRunning: boolean;
dequeueing: Set<number>;
selected: Set<number>;
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;
}
const {
queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
}: Props = $props();
</script>
{#if loading}
<div class="list">
{#each Array(5) as _, i (i)}
<div class="sk-row">
<div class="sk-thumb skeleton"></div>
<div class="sk-info">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="sk-progress-row">
<div class="skeleton sk-bar"></div>
<div class="skeleton sk-pages"></div>
</div>
</div>
<div class="sk-right">
<div class="skeleton sk-state"></div>
<div class="sk-actions">
<div class="skeleton sk-btn"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if queue.length === 0}
<div class="empty">Queue is empty.</div>
{:else}
<div class="list">
{#each queue as item, i (item.chapter.id)}
<DownloadItem
{item}
isActive={i === 0 && isRunning}
isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)}
{onRemove}
{onRetry}
{onReorder}
{onReorderEdge}
{onSelect}
/>
{/each}
</div>
{/if}
<style>
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.sk-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); pointer-events: none; }
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.sk-bar { flex: 1; height: 2px; }
.sk-pages { width: 28px; height: 9px; }
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.sk-state { width: 54px; height: 9px; }
.sk-actions { display: flex; gap: 2px; }
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
</style>
@@ -0,0 +1,230 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte";
import { formatEta } from "../lib/downloadQueue";
import { onMount } from "svelte";
onMount(() => {
downloadStore.poll();
});
let selectAnchor = $state<number | null>(null);
let moveBy = $state(1);
const selectedErrorCount = $derived(
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
);
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
const ctrl = e.ctrlKey || e.metaKey;
if (e.shiftKey && selectAnchor !== null) {
downloadStore.selectRange(selectAnchor, chapterId);
} else if (ctrl) {
downloadStore.toggleSelect(chapterId);
selectAnchor = chapterId;
} else {
if (downloadStore.selected.size > 1) {
downloadStore.toggleSelect(chapterId);
selectAnchor = chapterId;
} else if (downloadStore.selected.size === 1 && downloadStore.selected.has(chapterId)) {
downloadStore.clearSelection();
selectAnchor = null;
} else {
downloadStore.selectOnly(chapterId);
selectAnchor = chapterId;
}
}
}
function handleClickOff() {
if (downloadStore.selected.size > 0) {
downloadStore.clearSelection();
selectAnchor = null;
}
}
function clearSelection() {
downloadStore.clearSelection();
selectAnchor = null;
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button
class="icon-btn"
class:active={downloadStore.autoRetryEnabled}
onclick={() => downloadStore.toggleAutoRetry()}
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
>
<Repeat size={14} weight="regular" />
</button>
{#if downloadStore.hasErrored}
<button
class="icon-btn"
onclick={() => downloadStore.retryAllErrored()}
disabled={downloadStore.batchWorking}
title="Retry all errored"
>
{#if downloadStore.batchWorking}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowClockwise size={14} weight="bold" />
{/if}
</button>
{/if}
<button
class="icon-btn"
class:active={downloadStore.toastsEnabled}
onclick={() => downloadStore.toggleToasts()}
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
>
{#if downloadStore.toastsEnabled}
<Bell size={14} weight="regular" />
{:else}
<BellSlash size={14} weight="regular" />
{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.togglingPlay}
onclick={() => downloadStore.togglePlay()}
disabled={downloadStore.togglingPlay || (downloadStore.queue.length === 0 && !downloadStore.isRunning)}
title={downloadStore.isRunning ? "Pause" : "Resume"}
>
{#if downloadStore.togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if downloadStore.isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.clearing}
onclick={() => downloadStore.clear()}
disabled={downloadStore.clearing || downloadStore.queue.length === 0}
title="Clear queue"
>
{#if downloadStore.clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
</button>
</div>
</div>
<div class="bar-wrap">
<div class="status-bar" role="none">
<div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text">
{downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"}
</span>
{#if downloadStore.selected.size > 0}
<div class="sel-controls">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
<ArrowLineUp size={12} weight="bold" />
</button>
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
<CaretUp size={12} weight="bold" />
</button>
<input
class="move-input"
type="number"
min="1"
bind:value={moveBy}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
/>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
<CaretDown size={12} weight="bold" />
</button>
</div>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
<ArrowLineDown size={12} weight="bold" />
</button>
{#if selectedErrorCount > 0}
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
<ArrowClockwise size={12} weight="bold" />
</button>
{/if}
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
<X size={12} weight="bold" />
</button>
</div>
<div class="bar-sep"></div>
<span class="status-count">{downloadStore.selected.size} selected</span>
{:else}
<div class="status-right">
{#if downloadStore.isRunning && downloadStore.eta !== null}
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
{/if}
<span class="status-count">{downloadStore.queue.length} queued</span>
</div>
{/if}
</div>
</div>
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
<DownloadQueue
queue={downloadStore.queue}
loading={downloadStore.loading}
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
selected={downloadStore.selected}
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}
/>
</div>
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-2); }
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
.status-bar { cursor: default; }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
.move-input:focus { color: var(--text-primary); }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>