Implement chapter transfer functionality and enhance routing
- Added a new API endpoint for transferring selected chapters to a specified directory, including error handling and feedback for users. - Introduced a new TitlePage component to display detailed information about a title and its chapters, including transfer options. - Updated routing logic to support navigation to individual title pages, improving user experience. - Refactored existing components to streamline state management and enhance modularity. - Enhanced UI elements for better interaction, including checkboxes for selecting chapters and improved error/success messaging.
This commit is contained in:
@@ -6,6 +6,8 @@ services:
|
||||
environment:
|
||||
PORT: "3000"
|
||||
DATA_DIR: /app/data
|
||||
# Softlink destination for Transfer (default: $DATA_DIR/transfers).
|
||||
# TRANSFER_DIR: /app/data/transfers
|
||||
# Wake hourly; sync at most one due title per wake.
|
||||
WATCH_CRON: "0 * * * *"
|
||||
# Each title is eligible again after this many hours (daily).
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"start": "node src/server/index.js",
|
||||
"web": "node src/server/index.js",
|
||||
"dev:web": "vite --config web/vite.config.mjs",
|
||||
"build:web": "vite build --config web/vite.config.mjs",
|
||||
|
||||
+12
-1
@@ -13,6 +13,10 @@ const routesPlugin = require("./routes.js");
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
|
||||
/** Softlink destination for Transfer (default: data/transfers). */
|
||||
const TRANSFER_DIR = path.resolve(
|
||||
process.env.TRANSFER_DIR || path.join(DATA_DIR, "transfers"),
|
||||
);
|
||||
/** How often the watcher wakes (default: every hour). */
|
||||
const WATCH_CRON = process.env.WATCH_CRON || "0 * * * *";
|
||||
/** Only sync a title if it has not been checked for this many hours (default: daily). */
|
||||
@@ -24,6 +28,7 @@ const PUBLIC_DIR = path.join(process.cwd(), "public");
|
||||
*/
|
||||
async function main() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TRANSFER_DIR, { recursive: true });
|
||||
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
||||
|
||||
const db = openDatabase(DATA_DIR);
|
||||
@@ -55,7 +60,12 @@ async function main() {
|
||||
|
||||
await app.register(fastifyCookie);
|
||||
registerAuth(app, authConfig);
|
||||
await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR });
|
||||
await app.register(routesPlugin, {
|
||||
db,
|
||||
queue,
|
||||
dataDir: DATA_DIR,
|
||||
transferDir: TRANSFER_DIR,
|
||||
});
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: PUBLIC_DIR,
|
||||
@@ -93,6 +103,7 @@ async function main() {
|
||||
|
||||
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||
app.log.info(`Data dir: ${DATA_DIR}`);
|
||||
app.log.info(`Transfer dir: ${TRANSFER_DIR}`);
|
||||
app.log.info(`Watch cron: ${WATCH_CRON}`);
|
||||
app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`);
|
||||
app.log.info(`Trust proxy: ${trustProxy}`);
|
||||
|
||||
+90
-2
@@ -1,3 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const dbApi = require("./db.js");
|
||||
const {
|
||||
detectSource,
|
||||
@@ -5,16 +7,18 @@ const {
|
||||
fetchSeriesChapters,
|
||||
resolveCbzPath,
|
||||
cbzExists,
|
||||
sanitizeComicDirNameForSource,
|
||||
transferLinkBasename,
|
||||
} = require("./sources.js");
|
||||
const { syncTitle } = require("./watcher.js");
|
||||
const { formatUpstreamError } = require("../flaresolverr.js");
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify - Fastify app.
|
||||
* @param {{ db: import("better-sqlite3").Database, queue: import("./queue.js").DownloadQueue, dataDir: string }} opts - Shared deps.
|
||||
* @param {{ db: import("better-sqlite3").Database, queue: import("./queue.js").DownloadQueue, dataDir: string, transferDir: string }} opts - Shared deps.
|
||||
*/
|
||||
async function routesPlugin(fastify, opts) {
|
||||
const { db, queue, dataDir } = opts;
|
||||
const { db, queue, dataDir, transferDir } = opts;
|
||||
|
||||
fastify.get("/api/titles", async () => {
|
||||
return { titles: dbApi.listTitlesWithCounts(db) };
|
||||
@@ -185,6 +189,90 @@ async function routesPlugin(fastify, opts) {
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/api/titles/:id/transfer", async (request, reply) => {
|
||||
const id = Number(request.params.id);
|
||||
const title = dbApi.getTitle(db, id);
|
||||
if (!title) {
|
||||
return reply.code(404).send({ error: "Title not found" });
|
||||
}
|
||||
|
||||
const body = request.body || {};
|
||||
const rawIds = Array.isArray(body.chapterIds) ? body.chapterIds : [];
|
||||
const chapterIds = [
|
||||
...new Set(
|
||||
rawIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isInteger(n) && n > 0),
|
||||
),
|
||||
];
|
||||
if (chapterIds.length === 0) {
|
||||
return reply.code(400).send({ error: "chapterIds is required" });
|
||||
}
|
||||
|
||||
const comicDir = path.join(
|
||||
transferDir,
|
||||
sanitizeComicDirNameForSource(title.source, title.title),
|
||||
);
|
||||
fs.mkdirSync(comicDir, { recursive: true });
|
||||
|
||||
/** @type {Array<{ id: number, link: string }>} */
|
||||
const transferred = [];
|
||||
/** @type {Array<{ id: number, reason: string }>} */
|
||||
const skipped = [];
|
||||
/** @type {Set<string>} */
|
||||
const usedNames = new Set();
|
||||
|
||||
for (const chapterId of chapterIds) {
|
||||
const chapter = dbApi.getChapter(db, chapterId);
|
||||
if (!chapter || chapter.title_id !== id) {
|
||||
skipped.push({ id: chapterId, reason: "Chapter not found for this title" });
|
||||
continue;
|
||||
}
|
||||
if (chapter.status !== "downloaded") {
|
||||
skipped.push({ id: chapterId, reason: "Chapter is not downloaded" });
|
||||
continue;
|
||||
}
|
||||
if (!cbzExists(chapter.cbz_path)) {
|
||||
skipped.push({ id: chapterId, reason: "CBZ file missing" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let basename = transferLinkBasename(
|
||||
chapter.chapter_number,
|
||||
chapter.label,
|
||||
chapter.cbz_path,
|
||||
);
|
||||
if (usedNames.has(basename)) {
|
||||
const stem = basename.replace(/\.cbz$/iu, "");
|
||||
basename = `${stem}-${String(chapterId)}.cbz`;
|
||||
}
|
||||
usedNames.add(basename);
|
||||
|
||||
const dest = path.join(comicDir, basename);
|
||||
const target = path.resolve(chapter.cbz_path);
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.lstatSync(dest);
|
||||
fs.unlinkSync(dest);
|
||||
} catch (err) {
|
||||
if (err && err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
fs.symlinkSync(target, dest);
|
||||
transferred.push({ id: chapterId, link: dest });
|
||||
} catch (err) {
|
||||
skipped.push({
|
||||
id: chapterId,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, transferred, skipped };
|
||||
});
|
||||
|
||||
fastify.post("/api/chapters/:id/redownload", async (request, reply) => {
|
||||
const id = Number(request.params.id);
|
||||
const chapter = dbApi.getChapter(db, id);
|
||||
|
||||
@@ -236,6 +236,46 @@ function cbzExists(cbzPath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize comic folder name for a source (matches on-disk comic dirs).
|
||||
* @param {SourceId} source
|
||||
* @param {string} comicTitle
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeComicDirNameForSource(source, comicTitle) {
|
||||
return source === "manhwasusu"
|
||||
? manhwa.sanitizeComicDirName(comicTitle)
|
||||
: mangareadSanitizeDir(comicTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad leading chapter integer to 3 digits; keep decimal tail and letter suffix.
|
||||
* @param {string|null|undefined} chapterNumber
|
||||
* @param {string|null|undefined} [label]
|
||||
* @param {string|null|undefined} [cbzPath] - Fallback basename if parse fails.
|
||||
* @returns {string} e.g. "chapter 001.cbz"
|
||||
*/
|
||||
function transferLinkBasename(chapterNumber, label, cbzPath) {
|
||||
const candidates = [chapterNumber, label].filter(
|
||||
(s) => typeof s === "string" && s.trim(),
|
||||
);
|
||||
for (const raw of candidates) {
|
||||
const text = raw.trim();
|
||||
const m = /(\d+)((?:\.\d+)*)([a-zA-Z]*)/u.exec(text);
|
||||
if (m) {
|
||||
const padded = m[1].padStart(3, "0");
|
||||
return `chapter ${padded}${m[2] || ""}${m[3] || ""}.cbz`;
|
||||
}
|
||||
}
|
||||
if (cbzPath) {
|
||||
const base = path.basename(cbzPath);
|
||||
if (base) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
return "chapter unknown.cbz";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} cbzPath
|
||||
*/
|
||||
@@ -250,6 +290,8 @@ module.exports = {
|
||||
normalizeListingUrl,
|
||||
chapterKeyFromUrl,
|
||||
resolveCbzPath,
|
||||
sanitizeComicDirNameForSource,
|
||||
transferLinkBasename,
|
||||
fetchSeriesChapters,
|
||||
downloadChapter,
|
||||
cbzExists,
|
||||
|
||||
+157
-56
@@ -1,19 +1,24 @@
|
||||
<script>
|
||||
import { api as apiRequest } from "./lib/api.js";
|
||||
import { parseRoute, navigate, titlePath } from "./lib/route.js";
|
||||
import AppHeader from "./components/AppHeader.svelte";
|
||||
import LoginForm from "./components/LoginForm.svelte";
|
||||
import ErrorBanner from "./components/ErrorBanner.svelte";
|
||||
import AddTitleForm from "./components/AddTitleForm.svelte";
|
||||
import JobsStrip from "./components/JobsStrip.svelte";
|
||||
import TitleList from "./components/TitleList.svelte";
|
||||
import TitlePage from "./components/TitlePage.svelte";
|
||||
|
||||
let titles = $state([]);
|
||||
let jobs = $state([]);
|
||||
let chaptersByTitle = $state({});
|
||||
let selectedTitle = $state(null);
|
||||
let chapters = $state([]);
|
||||
let error = $state("");
|
||||
let success = $state("");
|
||||
let busy = $state(false);
|
||||
let loadingChapters = $state({});
|
||||
let expanded = $state({});
|
||||
let transferBusy = $state(false);
|
||||
let loadingChapters = $state(false);
|
||||
let route = $state(parseRoute());
|
||||
|
||||
let authReady = $state(false);
|
||||
let authRequired = $state(false);
|
||||
@@ -22,6 +27,11 @@
|
||||
let loginError = $state("");
|
||||
let loginBusy = $state(false);
|
||||
|
||||
function clearNotices() {
|
||||
error = "";
|
||||
success = "";
|
||||
}
|
||||
|
||||
function markUnauthorized() {
|
||||
if (authRequired) {
|
||||
authenticated = false;
|
||||
@@ -63,7 +73,7 @@
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
@@ -73,7 +83,9 @@
|
||||
authUsername = "";
|
||||
titles = [];
|
||||
jobs = [];
|
||||
chaptersByTitle = {};
|
||||
selectedTitle = null;
|
||||
chapters = [];
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -85,33 +97,43 @@
|
||||
jobs = j.jobs || [];
|
||||
}
|
||||
|
||||
async function loadChapters(titleId) {
|
||||
loadingChapters = { ...loadingChapters, [titleId]: true };
|
||||
try {
|
||||
const data = await api(`/api/titles/${titleId}/chapters`);
|
||||
chaptersByTitle = { ...chaptersByTitle, [titleId]: data.chapters || [] };
|
||||
} finally {
|
||||
loadingChapters = { ...loadingChapters, [titleId]: false };
|
||||
async function loadTitlePage(titleId, { quiet = false } = {}) {
|
||||
if (!quiet) {
|
||||
loadingChapters = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(titleId) {
|
||||
const next = !expanded[titleId];
|
||||
expanded = { ...expanded, [titleId]: next };
|
||||
if (next && !chaptersByTitle[titleId]) {
|
||||
await loadChapters(titleId);
|
||||
try {
|
||||
const [t, c] = await Promise.all([
|
||||
api(`/api/titles/${titleId}`),
|
||||
api(`/api/titles/${titleId}/chapters`),
|
||||
]);
|
||||
selectedTitle = t.title || null;
|
||||
chapters = c.chapters || [];
|
||||
if (!quiet) {
|
||||
error = "";
|
||||
}
|
||||
} catch (err) {
|
||||
selectedTitle = null;
|
||||
chapters = [];
|
||||
if (!quiet) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
loadingChapters = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTitle(url) {
|
||||
error = "";
|
||||
clearNotices();
|
||||
busy = true;
|
||||
try {
|
||||
await api("/api/titles", {
|
||||
const data = await api("/api/titles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
await refresh();
|
||||
if (data.title?.id) {
|
||||
navigate(titlePath(data.title.id));
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
@@ -120,13 +142,13 @@
|
||||
}
|
||||
|
||||
async function syncTitle(id) {
|
||||
error = "";
|
||||
clearNotices();
|
||||
busy = true;
|
||||
try {
|
||||
await api(`/api/titles/${id}/sync`, { method: "POST" });
|
||||
await refresh();
|
||||
if (expanded[id]) {
|
||||
await loadChapters(id);
|
||||
if (route.name === "title" && route.id === id) {
|
||||
await loadTitlePage(id);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -136,13 +158,16 @@
|
||||
}
|
||||
|
||||
async function toggleEnabled(title) {
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
await api(`/api/titles/${title.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ enabled: !title.enabled }),
|
||||
});
|
||||
await refresh();
|
||||
if (route.name === "title" && route.id === title.id) {
|
||||
await loadTitlePage(title.id, { quiet: true });
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -152,50 +177,90 @@
|
||||
if (!confirm("Remove this title from the watch list?")) {
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
const data = await api(`/api/titles/${id}`, { method: "DELETE" });
|
||||
const nextCh = { ...chaptersByTitle };
|
||||
delete nextCh[id];
|
||||
chaptersByTitle = nextCh;
|
||||
if (data.jobs) {
|
||||
jobs = data.jobs;
|
||||
}
|
||||
await refresh();
|
||||
if (route.name === "title" && route.id === id) {
|
||||
selectedTitle = null;
|
||||
chapters = [];
|
||||
navigate("/");
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function redownload(chapterId, titleId) {
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
await api(`/api/chapters/${chapterId}/redownload`, { method: "POST" });
|
||||
await refresh();
|
||||
await loadChapters(titleId);
|
||||
if (titleId) {
|
||||
await loadTitlePage(titleId, { quiet: true });
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelChapter(chapterId, titleId) {
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
await api(`/api/chapters/${chapterId}/cancel`, { method: "POST" });
|
||||
await refresh();
|
||||
if (titleId && expanded[titleId]) {
|
||||
await loadChapters(titleId);
|
||||
if (titleId && route.name === "title" && route.id === titleId) {
|
||||
await loadTitlePage(titleId, { quiet: true });
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function transferChapters(titleId, chapterIds) {
|
||||
clearNotices();
|
||||
transferBusy = true;
|
||||
try {
|
||||
const data = await api(`/api/titles/${titleId}/transfer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chapterIds }),
|
||||
});
|
||||
const transferred = Array.isArray(data.transferred) ? data.transferred.length : 0;
|
||||
const skipped = Array.isArray(data.skipped) ? data.skipped : [];
|
||||
if (skipped.length > 0 && transferred === 0) {
|
||||
error =
|
||||
skipped.map((s) => s.reason).filter(Boolean).join("; ") || "Transfer failed";
|
||||
} else if (skipped.length > 0) {
|
||||
success = `Transferred ${String(transferred)} chapter${transferred === 1 ? "" : "s"}`;
|
||||
error = `Skipped ${String(skipped.length)}: ${skipped
|
||||
.map((s) => s.reason)
|
||||
.filter(Boolean)
|
||||
.join("; ")}`;
|
||||
} else if (transferred > 0) {
|
||||
success = `Transferred ${String(transferred)} chapter${transferred === 1 ? "" : "s"}`;
|
||||
} else {
|
||||
error = "Nothing transferred";
|
||||
}
|
||||
await refresh();
|
||||
if (route.name === "title" && route.id === titleId) {
|
||||
await loadTitlePage(titleId, { quiet: true });
|
||||
}
|
||||
} catch (err) {
|
||||
success = "";
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
transferBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearQueue() {
|
||||
if (!confirm("Clear all waiting download jobs? The current download (if any) will finish.")) {
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
clearNotices();
|
||||
try {
|
||||
const data = await api("/api/jobs/clear", { method: "POST" });
|
||||
jobs = data.jobs || [];
|
||||
@@ -205,6 +270,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const onPop = () => {
|
||||
route = parseRoute();
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void checkAuth().catch((err) => {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -216,7 +289,26 @@
|
||||
if (!authReady || (authRequired && !authenticated)) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
const current = route;
|
||||
void (async () => {
|
||||
try {
|
||||
await refresh();
|
||||
if (current.name === "title") {
|
||||
await loadTitlePage(current.id);
|
||||
} else {
|
||||
selectedTitle = null;
|
||||
chapters = [];
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
})();
|
||||
|
||||
// Poll home list/jobs only — comic page loads once and refreshes on actions.
|
||||
if (current.name === "title") {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
void (async () => {
|
||||
if (authRequired && !authenticated) {
|
||||
@@ -224,11 +316,6 @@
|
||||
}
|
||||
try {
|
||||
await refresh();
|
||||
for (const [titleId, isOpen] of Object.entries(expanded)) {
|
||||
if (isOpen) {
|
||||
await loadChapters(Number(titleId));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 401 handled in api() */
|
||||
}
|
||||
@@ -250,21 +337,35 @@
|
||||
{:else if authRequired && !authenticated}
|
||||
<LoginForm error={loginError} busy={loginBusy} onSubmit={login} />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
<AddTitleForm {busy} onSubmit={addTitle} />
|
||||
<ErrorBanner message={success} tone="success" />
|
||||
<ErrorBanner message={error} tone="error" />
|
||||
{#if route.name === "home"}
|
||||
<AddTitleForm {busy} onSubmit={addTitle} />
|
||||
{/if}
|
||||
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
|
||||
<TitleList
|
||||
{titles}
|
||||
{expanded}
|
||||
{loadingChapters}
|
||||
{chaptersByTitle}
|
||||
{busy}
|
||||
onToggleExpand={toggleExpand}
|
||||
onSync={syncTitle}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onRemove={removeTitle}
|
||||
onCancelChapter={cancelChapter}
|
||||
onRedownload={redownload}
|
||||
/>
|
||||
{#if route.name === "title"}
|
||||
<TitlePage
|
||||
title={selectedTitle}
|
||||
loading={loadingChapters}
|
||||
{chapters}
|
||||
{busy}
|
||||
{transferBusy}
|
||||
onBack={() => navigate("/")}
|
||||
onSync={() => selectedTitle && syncTitle(selectedTitle.id)}
|
||||
onToggleEnabled={() => selectedTitle && toggleEnabled(selectedTitle)}
|
||||
onRemove={() => selectedTitle && removeTitle(selectedTitle.id)}
|
||||
onCancelChapter={cancelChapter}
|
||||
onRedownload={redownload}
|
||||
onTransfer={transferChapters}
|
||||
/>
|
||||
{:else}
|
||||
<TitleList
|
||||
{titles}
|
||||
{busy}
|
||||
onSync={syncTitle}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onRemove={removeTitle}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
+273
-18
@@ -42,6 +42,24 @@ h1 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.home-link:hover,
|
||||
.home-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.back-line {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title-meta a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -86,19 +104,244 @@ form.login-form button {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.title-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: calc(var(--pico-border-radius) * 1.25);
|
||||
background: var(--pico-card-background-color, var(--pico-background-color));
|
||||
box-shadow: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.title-card:hover,
|
||||
.title-card:focus-within {
|
||||
border-color: var(--pico-primary);
|
||||
box-shadow: 0 0 0 1px var(--pico-primary);
|
||||
}
|
||||
|
||||
.title-card-hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.title-card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title-card-body .row-actions {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.title-card-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.title-card-name,
|
||||
.title-page-name {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.title-page-name {
|
||||
font-size: clamp(1.25rem, 4vw, 1.6rem);
|
||||
}
|
||||
|
||||
.source-pill {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pico-muted-color);
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--pico-muted-border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--pico-primary);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-page-header {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.chapters-heading {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chapters-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.transfer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.select-all-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chapter-card-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.chapter-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
flex: 1 1 12rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chapter-check .chapter-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.check-col {
|
||||
width: 2.25rem;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Pico uses appearance:none + background-image for the tick; width:auto collapses it. */
|
||||
.chapter-check input[type="checkbox"],
|
||||
.check-col input[type="checkbox"],
|
||||
.select-all-check input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 1.35em;
|
||||
height: 1.35em;
|
||||
min-width: 1.35em;
|
||||
min-height: 1.35em;
|
||||
margin: 0.15rem 0 0;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: var(--pico-border-width) solid var(--pico-muted-border-color);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--pico-background-color);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.85em auto;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-col input[type="checkbox"],
|
||||
.select-all-check input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chapter-check input[type="checkbox"]:checked,
|
||||
.check-col input[type="checkbox"]:checked,
|
||||
.select-all-check input[type="checkbox"]:checked,
|
||||
.chapter-check input[type="checkbox"]:indeterminate,
|
||||
.check-col input[type="checkbox"]:indeterminate,
|
||||
.select-all-check input[type="checkbox"]:indeterminate {
|
||||
background-color: var(--pico-primary-background);
|
||||
border-color: var(--pico-primary-border);
|
||||
background-image: var(--pico-icon-checkbox);
|
||||
}
|
||||
|
||||
.chapter-check input[type="checkbox"]:indeterminate,
|
||||
.check-col input[type="checkbox"]:indeterminate,
|
||||
.select-all-check input[type="checkbox"]:indeterminate {
|
||||
background-image: var(--pico-icon-minus);
|
||||
}
|
||||
|
||||
.chapter-check input[type="checkbox"]:disabled,
|
||||
.check-col input[type="checkbox"]:disabled,
|
||||
.select-all-check input[type="checkbox"]:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.transfer-actions button {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
.title-card .row-actions {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.row-actions button,
|
||||
.row-actions a[role="button"] {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
white-space: normal;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
padding-inline: 0.5rem;
|
||||
padding-inline: 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -128,9 +371,26 @@ form.login-form button {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: #c62828;
|
||||
.error-banner,
|
||||
.status-banner {
|
||||
overflow-wrap: anywhere;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.error-banner,
|
||||
.status-error {
|
||||
color: #c62828;
|
||||
background: color-mix(in srgb, #c62828 10%, transparent);
|
||||
border-color: color-mix(in srgb, #c62828 35%, transparent);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #1b7f3a;
|
||||
background: color-mix(in srgb, #1b7f3a 10%, transparent);
|
||||
border-color: color-mix(in srgb, #1b7f3a 35%, transparent);
|
||||
}
|
||||
|
||||
.jobs-strip {
|
||||
@@ -211,16 +471,6 @@ form.login-form button {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
article {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
article header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -266,7 +516,7 @@ form.add-title button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-wrap td:first-child {
|
||||
.table-wrap td:nth-child(2) {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 12rem;
|
||||
@@ -350,10 +600,15 @@ form.add-title button {
|
||||
grid-template-columns: unset;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
.row-actions button,
|
||||
.row-actions a[role="button"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.title-card .row-actions button:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
<form class="add-title" onsubmit={handleSubmit}>
|
||||
<label>
|
||||
Series URL
|
||||
<input type="url" bind:value={url} placeholder="" required disabled={busy} />
|
||||
<input type="url" bind:value={url} placeholder="https://" required disabled={busy} />
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !url.trim()}>
|
||||
{busy ? "Working…" : "Add & watch"}
|
||||
{busy ? "Working…" : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import { navigate } from "../lib/route.js";
|
||||
|
||||
/**
|
||||
* @typedef {object} Props
|
||||
* @property {boolean} showLogout
|
||||
@@ -11,7 +13,16 @@
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<h1>Watch & Download</h1>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
class="home-link"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate("/");
|
||||
}}>Watch & Download</a
|
||||
>
|
||||
</h1>
|
||||
{#if showLogout}
|
||||
<div class="header-auth">
|
||||
{#if username}
|
||||
|
||||
@@ -11,19 +11,62 @@
|
||||
* @typedef {object} Props
|
||||
* @property {Chapter[]} chapters
|
||||
* @property {number} titleId
|
||||
* @property {number[]} selectedIds
|
||||
* @property {(chapterId: number, checked: boolean) => void} onToggle
|
||||
* @property {(checked: boolean) => void} onToggleAll
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancel
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let { chapters = [], titleId, onCancel, onRedownload } = $props();
|
||||
let {
|
||||
chapters = [],
|
||||
titleId,
|
||||
selectedIds = [],
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onCancel,
|
||||
onRedownload,
|
||||
} = $props();
|
||||
|
||||
/** @type {HTMLInputElement | undefined} */
|
||||
let selectAllEl = $state();
|
||||
|
||||
const downloadedIds = $derived(
|
||||
chapters.filter((ch) => ch.status === "downloaded").map((ch) => ch.id),
|
||||
);
|
||||
const selectedSet = $derived(new Set(selectedIds));
|
||||
const allDownloadedSelected = $derived(
|
||||
downloadedIds.length > 0 && downloadedIds.every((id) => selectedSet.has(id)),
|
||||
);
|
||||
const someDownloadedSelected = $derived(
|
||||
downloadedIds.some((id) => selectedSet.has(id)) && !allDownloadedSelected,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (selectAllEl) {
|
||||
selectAllEl.indeterminate = someDownloadedSelected;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ul class="chapter-list">
|
||||
{#each chapters as ch (ch.id)}
|
||||
{@const selectable = ch.status === "downloaded"}
|
||||
<li class="chapter-card">
|
||||
<div class="chapter-label">{ch.label}</div>
|
||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||
<div class="chapter-card-main">
|
||||
<label class="chapter-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!selectable}
|
||||
checked={selectedSet.has(ch.id)}
|
||||
onchange={(event) =>
|
||||
onToggle(ch.id, /** @type {HTMLInputElement} */ (event.currentTarget).checked)}
|
||||
/>
|
||||
<span class="chapter-label">{ch.label}</span>
|
||||
</label>
|
||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||
</div>
|
||||
{#if ch.error}
|
||||
<div class="muted chapter-error">{ch.error}</div>
|
||||
{/if}
|
||||
@@ -49,6 +92,17 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all downloaded"
|
||||
disabled={downloadedIds.length === 0}
|
||||
checked={allDownloadedSelected}
|
||||
bind:this={selectAllEl}
|
||||
onchange={(event) =>
|
||||
onToggleAll(/** @type {HTMLInputElement} */ (event.currentTarget).checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>Chapter</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
@@ -56,7 +110,18 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each chapters as ch (ch.id)}
|
||||
{@const selectable = ch.status === "downloaded"}
|
||||
<tr>
|
||||
<td class="check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!selectable}
|
||||
checked={selectedSet.has(ch.id)}
|
||||
aria-label={`Select ${ch.label}`}
|
||||
onchange={(event) =>
|
||||
onToggle(ch.id, /** @type {HTMLInputElement} */ (event.currentTarget).checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>{ch.label}</td>
|
||||
<td>
|
||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {object} Props
|
||||
* @property {string} message
|
||||
* @property {string} [message]
|
||||
* @property {"error" | "success"} [tone]
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let { message } = $props();
|
||||
let { message = "", tone = "error" } = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<p class="error-banner" role="alert">{message}</p>
|
||||
<p class={tone === "success" ? "status-banner status-success" : "status-banner status-error"} role="status">
|
||||
{message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
let { jobs = [], onClear, onCancel } = $props();
|
||||
</script>
|
||||
|
||||
{#if jobs.length > 0}
|
||||
<section class="jobs-strip" aria-live="polite">
|
||||
<div class="section-heading">
|
||||
<h2>Activity</h2>
|
||||
@@ -33,9 +34,6 @@
|
||||
Clear queue
|
||||
</button>
|
||||
</div>
|
||||
{#if jobs.length === 0}
|
||||
<p class="muted">No active jobs.</p>
|
||||
{:else}
|
||||
<ul class="job-list">
|
||||
{#each jobs as job (job.id)}
|
||||
<li class="job-row">
|
||||
@@ -58,5 +56,5 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import ChapterList from "./ChapterList.svelte";
|
||||
import { titlePath, navigate } from "../lib/route.js";
|
||||
|
||||
/**
|
||||
* @typedef {object} Title
|
||||
@@ -18,82 +18,65 @@
|
||||
/**
|
||||
* @typedef {object} Props
|
||||
* @property {Title} title
|
||||
* @property {boolean} expanded
|
||||
* @property {boolean} loading
|
||||
* @property {any[]} chapters
|
||||
* @property {boolean} busy
|
||||
* @property {() => void | Promise<void>} onToggleExpand
|
||||
* @property {() => void | Promise<void>} onSync
|
||||
* @property {() => void | Promise<void>} onToggleEnabled
|
||||
* @property {() => void | Promise<void>} onRemove
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancelChapter
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let {
|
||||
title,
|
||||
expanded = false,
|
||||
loading = false,
|
||||
chapters = [],
|
||||
busy = false,
|
||||
onToggleExpand,
|
||||
onSync,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
onCancelChapter,
|
||||
onRedownload,
|
||||
} = $props();
|
||||
let { title, busy = false, onSync, onToggleEnabled, onRemove } = $props();
|
||||
|
||||
const href = $derived(titlePath(title.id));
|
||||
const percent = $derived(
|
||||
title.total > 0 ? Math.round((title.downloaded / title.total) * 100) : 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
function openTitle(event) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<div class="title-meta">
|
||||
<strong>{title.title}</strong>
|
||||
<span class="muted">{title.source}</span>
|
||||
<article class="title-card">
|
||||
<a class="title-card-hit" {href} onclick={openTitle} aria-label={`Open ${title.title}`}></a>
|
||||
<div class="title-card-body">
|
||||
<div class="title-card-top">
|
||||
<strong class="title-card-name">{title.title}</strong>
|
||||
<span class="source-pill">{title.source}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div class="progress-fill" style={`width: ${percent}%`}></div>
|
||||
</div>
|
||||
<span class="progress-text">
|
||||
{title.downloaded} / {title.total} downloaded
|
||||
{title.downloaded}/{title.total}
|
||||
{#if title.downloading > 0}
|
||||
· {title.downloading} downloading
|
||||
· {title.downloading} dl
|
||||
{/if}
|
||||
{#if title.failed > 0}
|
||||
· {title.failed} failed
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted url-line">
|
||||
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
||||
<p class="muted card-meta">
|
||||
{title.enabled ? "Watching" : "Paused"}
|
||||
{#if title.last_checked_at}
|
||||
· last checked {title.last_checked_at}
|
||||
· {title.last_checked_at}
|
||||
{/if}
|
||||
· watch {title.enabled ? "on" : "off"}
|
||||
</p>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="outline" onclick={onToggleExpand}>
|
||||
{expanded ? "Hide chapters" : "Show chapters"}
|
||||
</button>
|
||||
<button type="button" class="outline" disabled={busy} onclick={onSync}>
|
||||
Sync now
|
||||
</button>
|
||||
<button type="button" class="outline" disabled={busy} onclick={onSync}>Sync</button>
|
||||
<button type="button" class="outline secondary" onclick={onToggleEnabled}>
|
||||
{title.enabled ? "Disable watch" : "Enable watch"}
|
||||
</button>
|
||||
<button type="button" class="outline contrast" onclick={onRemove}>
|
||||
Remove
|
||||
{title.enabled ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
<button type="button" class="outline contrast" onclick={onRemove}>Remove</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if expanded}
|
||||
{#if loading && chapters.length === 0}
|
||||
<p class="muted">Loading chapters…</p>
|
||||
{:else}
|
||||
<ChapterList
|
||||
{chapters}
|
||||
titleId={title.id}
|
||||
onCancel={onCancelChapter}
|
||||
{onRedownload}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -4,53 +4,31 @@
|
||||
/**
|
||||
* @typedef {object} Props
|
||||
* @property {any[]} titles
|
||||
* @property {Record<string|number, boolean>} expanded
|
||||
* @property {Record<string|number, boolean>} loadingChapters
|
||||
* @property {Record<string|number, any[]>} chaptersByTitle
|
||||
* @property {boolean} busy
|
||||
* @property {(titleId: number) => void | Promise<void>} onToggleExpand
|
||||
* @property {(titleId: number) => void | Promise<void>} onSync
|
||||
* @property {(title: any) => void | Promise<void>} onToggleEnabled
|
||||
* @property {(titleId: number) => void | Promise<void>} onRemove
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancelChapter
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let {
|
||||
titles = [],
|
||||
expanded = {},
|
||||
loadingChapters = {},
|
||||
chaptersByTitle = {},
|
||||
busy = false,
|
||||
onToggleExpand,
|
||||
onSync,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
onCancelChapter,
|
||||
onRedownload,
|
||||
} = $props();
|
||||
let { titles = [], busy = false, onSync, onToggleEnabled, onRemove } = $props();
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>Titles</h2>
|
||||
<section class="title-list">
|
||||
<h2>Comics</h2>
|
||||
{#if titles.length === 0}
|
||||
<p class="muted">No titles yet. Add a series URL above.</p>
|
||||
<p class="muted">No comics yet. Add a series URL above.</p>
|
||||
{:else}
|
||||
<div class="title-grid">
|
||||
{#each titles as title (title.id)}
|
||||
<TitleCard
|
||||
{title}
|
||||
expanded={Boolean(expanded[title.id])}
|
||||
loading={Boolean(loadingChapters[title.id])}
|
||||
chapters={chaptersByTitle[title.id] || []}
|
||||
{busy}
|
||||
onToggleExpand={() => onToggleExpand(title.id)}
|
||||
onSync={() => onSync(title.id)}
|
||||
onToggleEnabled={() => onToggleEnabled(title)}
|
||||
onRemove={() => onRemove(title.id)}
|
||||
{onCancelChapter}
|
||||
{onRedownload}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import { untrack } from "svelte";
|
||||
import ChapterList from "./ChapterList.svelte";
|
||||
|
||||
/**
|
||||
* @typedef {object} Title
|
||||
* @property {number} id
|
||||
* @property {string} title
|
||||
* @property {string} source
|
||||
* @property {string} url
|
||||
* @property {boolean} enabled
|
||||
* @property {number} downloaded
|
||||
* @property {number} total
|
||||
* @property {number} downloading
|
||||
* @property {number} failed
|
||||
* @property {string} [last_checked_at]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Props
|
||||
* @property {Title | null} title
|
||||
* @property {boolean} loading
|
||||
* @property {any[]} chapters
|
||||
* @property {boolean} busy
|
||||
* @property {boolean} [transferBusy]
|
||||
* @property {() => void} onBack
|
||||
* @property {() => void | Promise<void>} onSync
|
||||
* @property {() => void | Promise<void>} onToggleEnabled
|
||||
* @property {() => void | Promise<void>} onRemove
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancelChapter
|
||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||
* @property {(titleId: number, chapterIds: number[]) => void | Promise<void>} onTransfer
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let {
|
||||
title = null,
|
||||
loading = false,
|
||||
chapters = [],
|
||||
busy = false,
|
||||
transferBusy = false,
|
||||
onBack,
|
||||
onSync,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
onCancelChapter,
|
||||
onRedownload,
|
||||
onTransfer,
|
||||
} = $props();
|
||||
|
||||
/** @type {number[]} */
|
||||
let selectedIds = $state([]);
|
||||
/** @type {number | null | undefined} */
|
||||
let selectionTitleId = undefined;
|
||||
|
||||
const percent = $derived(
|
||||
title && title.total > 0 ? Math.round((title.downloaded / title.total) * 100) : 0,
|
||||
);
|
||||
|
||||
const downloadedIds = $derived(
|
||||
chapters.filter((ch) => ch.status === "downloaded").map((ch) => ch.id),
|
||||
);
|
||||
|
||||
// Drop selections that are no longer downloadable / present after poll refresh.
|
||||
$effect(() => {
|
||||
const allowed = new Set(downloadedIds);
|
||||
const current = untrack(() => selectedIds);
|
||||
const next = current.filter((id) => allowed.has(id));
|
||||
if (next.length !== current.length) {
|
||||
selectedIds = next;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selection only when navigating to a different comic (not on poll refresh).
|
||||
$effect(() => {
|
||||
const titleId = title?.id ?? null;
|
||||
if (titleId !== selectionTitleId) {
|
||||
selectionTitleId = titleId;
|
||||
selectedIds = [];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} chapterId
|
||||
* @param {boolean} checked
|
||||
*/
|
||||
function toggleChapter(chapterId, checked) {
|
||||
if (checked) {
|
||||
if (!selectedIds.includes(chapterId)) {
|
||||
selectedIds = [...selectedIds, chapterId];
|
||||
}
|
||||
} else {
|
||||
selectedIds = selectedIds.filter((id) => id !== chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} checked
|
||||
*/
|
||||
function toggleAll(checked) {
|
||||
selectedIds = checked ? [...downloadedIds] : [];
|
||||
}
|
||||
|
||||
async function handleTransfer() {
|
||||
if (!title || selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await onTransfer(title.id, selectedIds);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="title-page">
|
||||
<p class="back-line">
|
||||
<a
|
||||
href="/"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
onBack();
|
||||
}}>← Comics</a
|
||||
>
|
||||
</p>
|
||||
|
||||
{#if !title}
|
||||
{#if loading}
|
||||
<p class="muted">Loading comic…</p>
|
||||
{:else}
|
||||
<p class="muted">Comic not found.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<header class="title-page-header">
|
||||
<div class="title-card-top">
|
||||
<h2 class="title-page-name">{title.title}</h2>
|
||||
<span class="source-pill">{title.source}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div class="progress-fill" style={`width: ${percent}%`}></div>
|
||||
</div>
|
||||
<span class="progress-text">
|
||||
{title.downloaded}/{title.total} downloaded
|
||||
{#if title.downloading > 0}
|
||||
· {title.downloading} downloading
|
||||
{/if}
|
||||
{#if title.failed > 0}
|
||||
· {title.failed} failed
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted url-line">
|
||||
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
||||
{#if title.last_checked_at}
|
||||
· last checked {title.last_checked_at}
|
||||
{/if}
|
||||
· {title.enabled ? "Watching" : "Paused"}
|
||||
</p>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="outline" disabled={busy} onclick={onSync}>Sync</button>
|
||||
<button type="button" class="outline secondary" onclick={onToggleEnabled}>
|
||||
{title.enabled ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
<button type="button" class="outline contrast" onclick={onRemove}>Remove</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chapters-toolbar">
|
||||
<h3 class="chapters-heading">Chapters</h3>
|
||||
<div class="transfer-actions">
|
||||
<label class="select-all-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={downloadedIds.length === 0}
|
||||
checked={downloadedIds.length > 0 &&
|
||||
downloadedIds.every((id) => selectedIds.includes(id))}
|
||||
onchange={(event) =>
|
||||
toggleAll(/** @type {HTMLInputElement} */ (event.currentTarget).checked)}
|
||||
/>
|
||||
<span class="muted">All</span>
|
||||
</label>
|
||||
{#if selectedIds.length > 0}
|
||||
<span class="muted">{selectedIds.length} selected</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="outline"
|
||||
disabled={selectedIds.length === 0 || transferBusy}
|
||||
onclick={handleTransfer}
|
||||
>
|
||||
{transferBusy ? "Transferring…" : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if loading && chapters.length === 0}
|
||||
<p class="muted">Loading chapters…</p>
|
||||
{:else if chapters.length === 0}
|
||||
<p class="muted">No chapters yet. Sync to fetch the listing.</p>
|
||||
{:else}
|
||||
<ChapterList
|
||||
{chapters}
|
||||
titleId={title.id}
|
||||
{selectedIds}
|
||||
onToggle={toggleChapter}
|
||||
onToggleAll={toggleAll}
|
||||
onCancel={onCancelChapter}
|
||||
{onRedownload}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @typedef {{ name: "home" } | { name: "title", id: number }} AppRoute
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} [pathname]
|
||||
* @returns {AppRoute}
|
||||
*/
|
||||
export function parseRoute(pathname = window.location.pathname) {
|
||||
const match = pathname.match(/^\/titles\/(\d+)\/?$/);
|
||||
if (match) {
|
||||
return { name: "title", id: Number(match[1]) };
|
||||
}
|
||||
return { name: "home" };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function titlePath(id) {
|
||||
return `/titles/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} href
|
||||
*/
|
||||
export function navigate(href) {
|
||||
if (window.location.pathname === href) {
|
||||
return;
|
||||
}
|
||||
history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
Reference in New Issue
Block a user