Feat: Reworked ENTIRE Project for Readability

This commit is contained in:
Youwes09
2026-04-20 00:19:22 -05:00
parent 005680394e
commit 4b97f4a6c9
191 changed files with 19210 additions and 15915 deletions
@@ -0,0 +1,145 @@
<script lang="ts">
import { CircleNotch, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
onRemove: (chapterId: number) => void;
}
const { item, isActive, isRemoving, onRemove }: Props = $props();
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
</script>
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#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>
{/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}
</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);
}
.row.row-active { border-color: var(--accent-dim); }
.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: 3px;
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;
}
.pages-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progress-wrap {
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;
}
.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;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
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>
@@ -0,0 +1,51 @@
<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>;
onRemove: (chapterId: number) => void;
}
const { queue, loading, isRunning, dequeueing, onRemove }: Props = $props();
</script>
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></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)}
{onRemove}
/>
{/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);
}
</style>
@@ -0,0 +1,142 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte";
$effect(() => {
downloadStore.poll();
const interval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(interval);
});
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<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="content">
<div class="status-bar">
<div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text">
{downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"}
</span>
<span class="status-count">{downloadStore.queue.length} queued</span>
</div>
<DownloadQueue
queue={downloadStore.queue}
loading={downloadStore.loading}
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
onRemove={(id) => downloadStore.dequeue(id)}
/>
</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); }
.content {
flex: 1;
overflow-y: auto;
padding: var(--sp-5) 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: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
color: var(--text-muted);
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar {
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);
}
.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-count {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+2
View File
@@ -0,0 +1,2 @@
export { downloadStore } from "./store/downloadState.svelte";
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
@@ -0,0 +1,61 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries/downloads";
import { addToast, setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
let prevQueue: DownloadQueueItem[] = [];
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({
kind: "success",
title: "Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000,
});
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
})));
}
export async function mountDownloadPoller(): Promise<() => void> {
const win = getCurrentWindow();
let paused = false;
let interval: ReturnType<typeof setInterval>;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue))
.catch(console.error);
};
poll();
interval = setInterval(poll, 2000);
const onVisibility = () => { paused = document.hidden; };
document.addEventListener("visibilitychange", onVisibility);
const unlistenFocus = await win.onFocusChanged(({ payload: focused }) => {
paused = !focused;
});
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus();
};
}
@@ -0,0 +1,25 @@
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
return queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}));
}
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
return queue.filter((i) => i.chapter.id !== chapterId);
}
export function optimisticToggle(state: string, wasRunning: boolean): string {
return wasRunning ? "STOPPED" : "STARTED";
}
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
@@ -0,0 +1,69 @@
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries";
import { START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "@api/mutations";
import { setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus } from "@types/index";
import { toActiveDownloads, optimisticRemove, isRunning } from "../lib/downloadQueue";
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
}
async poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => this.loading = false);
}
async togglePlay() {
if (this.togglingPlay) return;
this.togglingPlay = true;
const wasRunning = this.isRunning;
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
this.applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
this.applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); this.poll(); }
finally { this.togglingPlay = false; }
}
async clear() {
if (this.clearing) return;
this.clearing = true;
if (this.status) this.status = { ...this.status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; }
}
async dequeue(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
}
export const downloadStore = new DownloadStore();