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:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
DATA_DIR: /app/data
|
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.
|
# Wake hourly; sync at most one due title per wake.
|
||||||
WATCH_CRON: "0 * * * *"
|
WATCH_CRON: "0 * * * *"
|
||||||
# Each title is eligible again after this many hours (daily).
|
# Each title is eligible again after this many hours (daily).
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/server/index.js",
|
||||||
"web": "node src/server/index.js",
|
"web": "node src/server/index.js",
|
||||||
"dev:web": "vite --config web/vite.config.mjs",
|
"dev:web": "vite --config web/vite.config.mjs",
|
||||||
"build:web": "vite build --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 PORT = Number(process.env.PORT) || 3000;
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
|
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). */
|
/** How often the watcher wakes (default: every hour). */
|
||||||
const WATCH_CRON = process.env.WATCH_CRON || "0 * * * *";
|
const WATCH_CRON = process.env.WATCH_CRON || "0 * * * *";
|
||||||
/** Only sync a title if it has not been checked for this many hours (default: daily). */
|
/** 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() {
|
async function main() {
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(TRANSFER_DIR, { recursive: true });
|
||||||
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
||||||
|
|
||||||
const db = openDatabase(DATA_DIR);
|
const db = openDatabase(DATA_DIR);
|
||||||
@@ -55,7 +60,12 @@ async function main() {
|
|||||||
|
|
||||||
await app.register(fastifyCookie);
|
await app.register(fastifyCookie);
|
||||||
registerAuth(app, authConfig);
|
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, {
|
await app.register(fastifyStatic, {
|
||||||
root: PUBLIC_DIR,
|
root: PUBLIC_DIR,
|
||||||
@@ -93,6 +103,7 @@ async function main() {
|
|||||||
|
|
||||||
await app.listen({ port: PORT, host: "0.0.0.0" });
|
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
app.log.info(`Data dir: ${DATA_DIR}`);
|
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 cron: ${WATCH_CRON}`);
|
||||||
app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`);
|
app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`);
|
||||||
app.log.info(`Trust proxy: ${trustProxy}`);
|
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 dbApi = require("./db.js");
|
||||||
const {
|
const {
|
||||||
detectSource,
|
detectSource,
|
||||||
@@ -5,16 +7,18 @@ const {
|
|||||||
fetchSeriesChapters,
|
fetchSeriesChapters,
|
||||||
resolveCbzPath,
|
resolveCbzPath,
|
||||||
cbzExists,
|
cbzExists,
|
||||||
|
sanitizeComicDirNameForSource,
|
||||||
|
transferLinkBasename,
|
||||||
} = require("./sources.js");
|
} = require("./sources.js");
|
||||||
const { syncTitle } = require("./watcher.js");
|
const { syncTitle } = require("./watcher.js");
|
||||||
const { formatUpstreamError } = require("../flaresolverr.js");
|
const { formatUpstreamError } = require("../flaresolverr.js");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify - Fastify app.
|
* @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) {
|
async function routesPlugin(fastify, opts) {
|
||||||
const { db, queue, dataDir } = opts;
|
const { db, queue, dataDir, transferDir } = opts;
|
||||||
|
|
||||||
fastify.get("/api/titles", async () => {
|
fastify.get("/api/titles", async () => {
|
||||||
return { titles: dbApi.listTitlesWithCounts(db) };
|
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) => {
|
fastify.post("/api/chapters/:id/redownload", async (request, reply) => {
|
||||||
const id = Number(request.params.id);
|
const id = Number(request.params.id);
|
||||||
const chapter = dbApi.getChapter(db, 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
|
* @param {string|null|undefined} cbzPath
|
||||||
*/
|
*/
|
||||||
@@ -250,6 +290,8 @@ module.exports = {
|
|||||||
normalizeListingUrl,
|
normalizeListingUrl,
|
||||||
chapterKeyFromUrl,
|
chapterKeyFromUrl,
|
||||||
resolveCbzPath,
|
resolveCbzPath,
|
||||||
|
sanitizeComicDirNameForSource,
|
||||||
|
transferLinkBasename,
|
||||||
fetchSeriesChapters,
|
fetchSeriesChapters,
|
||||||
downloadChapter,
|
downloadChapter,
|
||||||
cbzExists,
|
cbzExists,
|
||||||
|
|||||||
+148
-47
@@ -1,19 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api as apiRequest } from "./lib/api.js";
|
import { api as apiRequest } from "./lib/api.js";
|
||||||
|
import { parseRoute, navigate, titlePath } from "./lib/route.js";
|
||||||
import AppHeader from "./components/AppHeader.svelte";
|
import AppHeader from "./components/AppHeader.svelte";
|
||||||
import LoginForm from "./components/LoginForm.svelte";
|
import LoginForm from "./components/LoginForm.svelte";
|
||||||
import ErrorBanner from "./components/ErrorBanner.svelte";
|
import ErrorBanner from "./components/ErrorBanner.svelte";
|
||||||
import AddTitleForm from "./components/AddTitleForm.svelte";
|
import AddTitleForm from "./components/AddTitleForm.svelte";
|
||||||
import JobsStrip from "./components/JobsStrip.svelte";
|
import JobsStrip from "./components/JobsStrip.svelte";
|
||||||
import TitleList from "./components/TitleList.svelte";
|
import TitleList from "./components/TitleList.svelte";
|
||||||
|
import TitlePage from "./components/TitlePage.svelte";
|
||||||
|
|
||||||
let titles = $state([]);
|
let titles = $state([]);
|
||||||
let jobs = $state([]);
|
let jobs = $state([]);
|
||||||
let chaptersByTitle = $state({});
|
let selectedTitle = $state(null);
|
||||||
|
let chapters = $state([]);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let success = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let loadingChapters = $state({});
|
let transferBusy = $state(false);
|
||||||
let expanded = $state({});
|
let loadingChapters = $state(false);
|
||||||
|
let route = $state(parseRoute());
|
||||||
|
|
||||||
let authReady = $state(false);
|
let authReady = $state(false);
|
||||||
let authRequired = $state(false);
|
let authRequired = $state(false);
|
||||||
@@ -22,6 +27,11 @@
|
|||||||
let loginError = $state("");
|
let loginError = $state("");
|
||||||
let loginBusy = $state(false);
|
let loginBusy = $state(false);
|
||||||
|
|
||||||
|
function clearNotices() {
|
||||||
|
error = "";
|
||||||
|
success = "";
|
||||||
|
}
|
||||||
|
|
||||||
function markUnauthorized() {
|
function markUnauthorized() {
|
||||||
if (authRequired) {
|
if (authRequired) {
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
@@ -63,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
await api("/api/auth/logout", { method: "POST" });
|
await api("/api/auth/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,7 +83,9 @@
|
|||||||
authUsername = "";
|
authUsername = "";
|
||||||
titles = [];
|
titles = [];
|
||||||
jobs = [];
|
jobs = [];
|
||||||
chaptersByTitle = {};
|
selectedTitle = null;
|
||||||
|
chapters = [];
|
||||||
|
navigate("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -85,33 +97,43 @@
|
|||||||
jobs = j.jobs || [];
|
jobs = j.jobs || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChapters(titleId) {
|
async function loadTitlePage(titleId, { quiet = false } = {}) {
|
||||||
loadingChapters = { ...loadingChapters, [titleId]: true };
|
if (!quiet) {
|
||||||
|
loadingChapters = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/titles/${titleId}/chapters`);
|
const [t, c] = await Promise.all([
|
||||||
chaptersByTitle = { ...chaptersByTitle, [titleId]: data.chapters || [] };
|
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 {
|
} finally {
|
||||||
loadingChapters = { ...loadingChapters, [titleId]: false };
|
loadingChapters = false;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleExpand(titleId) {
|
|
||||||
const next = !expanded[titleId];
|
|
||||||
expanded = { ...expanded, [titleId]: next };
|
|
||||||
if (next && !chaptersByTitle[titleId]) {
|
|
||||||
await loadChapters(titleId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTitle(url) {
|
async function addTitle(url) {
|
||||||
error = "";
|
clearNotices();
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await api("/api/titles", {
|
const data = await api("/api/titles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (data.title?.id) {
|
||||||
|
navigate(titlePath(data.title.id));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,13 +142,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function syncTitle(id) {
|
async function syncTitle(id) {
|
||||||
error = "";
|
clearNotices();
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await api(`/api/titles/${id}/sync`, { method: "POST" });
|
await api(`/api/titles/${id}/sync`, { method: "POST" });
|
||||||
await refresh();
|
await refresh();
|
||||||
if (expanded[id]) {
|
if (route.name === "title" && route.id === id) {
|
||||||
await loadChapters(id);
|
await loadTitlePage(id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -136,13 +158,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleEnabled(title) {
|
async function toggleEnabled(title) {
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
await api(`/api/titles/${title.id}`, {
|
await api(`/api/titles/${title.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ enabled: !title.enabled }),
|
body: JSON.stringify({ enabled: !title.enabled }),
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (route.name === "title" && route.id === title.id) {
|
||||||
|
await loadTitlePage(title.id, { quiet: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
@@ -152,50 +177,90 @@
|
|||||||
if (!confirm("Remove this title from the watch list?")) {
|
if (!confirm("Remove this title from the watch list?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/titles/${id}`, { method: "DELETE" });
|
const data = await api(`/api/titles/${id}`, { method: "DELETE" });
|
||||||
const nextCh = { ...chaptersByTitle };
|
|
||||||
delete nextCh[id];
|
|
||||||
chaptersByTitle = nextCh;
|
|
||||||
if (data.jobs) {
|
if (data.jobs) {
|
||||||
jobs = data.jobs;
|
jobs = data.jobs;
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (route.name === "title" && route.id === id) {
|
||||||
|
selectedTitle = null;
|
||||||
|
chapters = [];
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function redownload(chapterId, titleId) {
|
async function redownload(chapterId, titleId) {
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
await api(`/api/chapters/${chapterId}/redownload`, { method: "POST" });
|
await api(`/api/chapters/${chapterId}/redownload`, { method: "POST" });
|
||||||
await refresh();
|
await refresh();
|
||||||
await loadChapters(titleId);
|
if (titleId) {
|
||||||
|
await loadTitlePage(titleId, { quiet: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelChapter(chapterId, titleId) {
|
async function cancelChapter(chapterId, titleId) {
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
await api(`/api/chapters/${chapterId}/cancel`, { method: "POST" });
|
await api(`/api/chapters/${chapterId}/cancel`, { method: "POST" });
|
||||||
await refresh();
|
await refresh();
|
||||||
if (titleId && expanded[titleId]) {
|
if (titleId && route.name === "title" && route.id === titleId) {
|
||||||
await loadChapters(titleId);
|
await loadTitlePage(titleId, { quiet: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(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() {
|
async function clearQueue() {
|
||||||
if (!confirm("Clear all waiting download jobs? The current download (if any) will finish.")) {
|
if (!confirm("Clear all waiting download jobs? The current download (if any) will finish.")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error = "";
|
clearNotices();
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/jobs/clear", { method: "POST" });
|
const data = await api("/api/jobs/clear", { method: "POST" });
|
||||||
jobs = data.jobs || [];
|
jobs = data.jobs || [];
|
||||||
@@ -205,6 +270,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const onPop = () => {
|
||||||
|
route = parseRoute();
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void checkAuth().catch((err) => {
|
void checkAuth().catch((err) => {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -216,7 +289,26 @@
|
|||||||
if (!authReady || (authRequired && !authenticated)) {
|
if (!authReady || (authRequired && !authenticated)) {
|
||||||
return;
|
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(() => {
|
const id = setInterval(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (authRequired && !authenticated) {
|
if (authRequired && !authenticated) {
|
||||||
@@ -224,11 +316,6 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await refresh();
|
||||||
for (const [titleId, isOpen] of Object.entries(expanded)) {
|
|
||||||
if (isOpen) {
|
|
||||||
await loadChapters(Number(titleId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* 401 handled in api() */
|
/* 401 handled in api() */
|
||||||
}
|
}
|
||||||
@@ -250,21 +337,35 @@
|
|||||||
{:else if authRequired && !authenticated}
|
{:else if authRequired && !authenticated}
|
||||||
<LoginForm error={loginError} busy={loginBusy} onSubmit={login} />
|
<LoginForm error={loginError} busy={loginBusy} onSubmit={login} />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={success} tone="success" />
|
||||||
|
<ErrorBanner message={error} tone="error" />
|
||||||
|
{#if route.name === "home"}
|
||||||
<AddTitleForm {busy} onSubmit={addTitle} />
|
<AddTitleForm {busy} onSubmit={addTitle} />
|
||||||
|
{/if}
|
||||||
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
|
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
|
||||||
|
{#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
|
<TitleList
|
||||||
{titles}
|
{titles}
|
||||||
{expanded}
|
|
||||||
{loadingChapters}
|
|
||||||
{chaptersByTitle}
|
|
||||||
{busy}
|
{busy}
|
||||||
onToggleExpand={toggleExpand}
|
|
||||||
onSync={syncTitle}
|
onSync={syncTitle}
|
||||||
onToggleEnabled={toggleEnabled}
|
onToggleEnabled={toggleEnabled}
|
||||||
onRemove={removeTitle}
|
onRemove={removeTitle}
|
||||||
onCancelChapter={cancelChapter}
|
|
||||||
onRedownload={redownload}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+273
-18
@@ -42,6 +42,24 @@ h1 {
|
|||||||
margin-bottom: 0;
|
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 {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -86,19 +104,244 @@ form.login-form button {
|
|||||||
word-break: break-word;
|
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 {
|
.row-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.5rem;
|
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%;
|
width: 100%;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
white-space: normal;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
padding-inline: 0.5rem;
|
padding-inline: 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -128,9 +371,26 @@ form.login-form button {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner,
|
||||||
color: #c62828;
|
.status-banner {
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.jobs-strip {
|
||||||
@@ -211,16 +471,6 @@ form.login-form button {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
article {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
article header {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -266,7 +516,7 @@ form.add-title button {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap td:first-child {
|
.table-wrap td:nth-child(2) {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
max-width: 12rem;
|
max-width: 12rem;
|
||||||
@@ -350,10 +600,15 @@ form.add-title button {
|
|||||||
grid-template-columns: unset;
|
grid-template-columns: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-actions button {
|
.row-actions button,
|
||||||
|
.row-actions a[role="button"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-card .row-actions button:last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.chapter-list {
|
.chapter-list {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@
|
|||||||
<form class="add-title" onsubmit={handleSubmit}>
|
<form class="add-title" onsubmit={handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Series URL
|
Series URL
|
||||||
<input type="url" bind:value={url} placeholder="" required disabled={busy} />
|
<input type="url" bind:value={url} placeholder="https://" required disabled={busy} />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={busy || !url.trim()}>
|
<button type="submit" disabled={busy || !url.trim()}>
|
||||||
{busy ? "Working…" : "Add & watch"}
|
{busy ? "Working…" : "Add"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { navigate } from "../lib/route.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} Props
|
* @typedef {object} Props
|
||||||
* @property {boolean} showLogout
|
* @property {boolean} showLogout
|
||||||
@@ -11,7 +13,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="app-header">
|
<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}
|
{#if showLogout}
|
||||||
<div class="header-auth">
|
<div class="header-auth">
|
||||||
{#if username}
|
{#if username}
|
||||||
|
|||||||
@@ -11,19 +11,62 @@
|
|||||||
* @typedef {object} Props
|
* @typedef {object} Props
|
||||||
* @property {Chapter[]} chapters
|
* @property {Chapter[]} chapters
|
||||||
* @property {number} titleId
|
* @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>} onCancel
|
||||||
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {Props} */
|
/** @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>
|
</script>
|
||||||
|
|
||||||
<ul class="chapter-list">
|
<ul class="chapter-list">
|
||||||
{#each chapters as ch (ch.id)}
|
{#each chapters as ch (ch.id)}
|
||||||
|
{@const selectable = ch.status === "downloaded"}
|
||||||
<li class="chapter-card">
|
<li class="chapter-card">
|
||||||
<div class="chapter-label">{ch.label}</div>
|
<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>
|
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||||
|
</div>
|
||||||
{#if ch.error}
|
{#if ch.error}
|
||||||
<div class="muted chapter-error">{ch.error}</div>
|
<div class="muted chapter-error">{ch.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -49,6 +92,17 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>Chapter</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -56,7 +110,18 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each chapters as ch (ch.id)}
|
{#each chapters as ch (ch.id)}
|
||||||
|
{@const selectable = ch.status === "downloaded"}
|
||||||
<tr>
|
<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>{ch.label}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
/**
|
/**
|
||||||
* @typedef {object} Props
|
* @typedef {object} Props
|
||||||
* @property {string} message
|
* @property {string} [message]
|
||||||
|
* @property {"error" | "success"} [tone]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {Props} */
|
/** @type {Props} */
|
||||||
let { message } = $props();
|
let { message = "", tone = "error" } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if message}
|
{#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}
|
{/if}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
let { jobs = [], onClear, onCancel } = $props();
|
let { jobs = [], onClear, onCancel } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if jobs.length > 0}
|
||||||
<section class="jobs-strip" aria-live="polite">
|
<section class="jobs-strip" aria-live="polite">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>Activity</h2>
|
<h2>Activity</h2>
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
Clear queue
|
Clear queue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if jobs.length === 0}
|
|
||||||
<p class="muted">No active jobs.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="job-list">
|
<ul class="job-list">
|
||||||
{#each jobs as job (job.id)}
|
{#each jobs as job (job.id)}
|
||||||
<li class="job-row">
|
<li class="job-row">
|
||||||
@@ -58,5 +56,5 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import ChapterList from "./ChapterList.svelte";
|
import { titlePath, navigate } from "../lib/route.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} Title
|
* @typedef {object} Title
|
||||||
@@ -18,82 +18,65 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {object} Props
|
* @typedef {object} Props
|
||||||
* @property {Title} title
|
* @property {Title} title
|
||||||
* @property {boolean} expanded
|
|
||||||
* @property {boolean} loading
|
|
||||||
* @property {any[]} chapters
|
|
||||||
* @property {boolean} busy
|
* @property {boolean} busy
|
||||||
* @property {() => void | Promise<void>} onToggleExpand
|
|
||||||
* @property {() => void | Promise<void>} onSync
|
* @property {() => void | Promise<void>} onSync
|
||||||
* @property {() => void | Promise<void>} onToggleEnabled
|
* @property {() => void | Promise<void>} onToggleEnabled
|
||||||
* @property {() => void | Promise<void>} onRemove
|
* @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} */
|
/** @type {Props} */
|
||||||
let {
|
let { title, busy = false, onSync, onToggleEnabled, onRemove } = $props();
|
||||||
title,
|
|
||||||
expanded = false,
|
const href = $derived(titlePath(title.id));
|
||||||
loading = false,
|
const percent = $derived(
|
||||||
chapters = [],
|
title.total > 0 ? Math.round((title.downloaded / title.total) * 100) : 0,
|
||||||
busy = false,
|
);
|
||||||
onToggleExpand,
|
|
||||||
onSync,
|
/**
|
||||||
onToggleEnabled,
|
* @param {MouseEvent} event
|
||||||
onRemove,
|
*/
|
||||||
onCancelChapter,
|
function openTitle(event) {
|
||||||
onRedownload,
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
} = $props();
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(href);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article>
|
<article class="title-card">
|
||||||
<header>
|
<a class="title-card-hit" {href} onclick={openTitle} aria-label={`Open ${title.title}`}></a>
|
||||||
<div class="title-meta">
|
<div class="title-card-body">
|
||||||
<strong>{title.title}</strong>
|
<div class="title-card-top">
|
||||||
<span class="muted">{title.source}</span>
|
<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">
|
<span class="progress-text">
|
||||||
{title.downloaded} / {title.total} downloaded
|
{title.downloaded}/{title.total}
|
||||||
{#if title.downloading > 0}
|
{#if title.downloading > 0}
|
||||||
· {title.downloading} downloading
|
· {title.downloading} dl
|
||||||
{/if}
|
{/if}
|
||||||
{#if title.failed > 0}
|
{#if title.failed > 0}
|
||||||
· {title.failed} failed
|
· {title.failed} failed
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted url-line">
|
<p class="muted card-meta">
|
||||||
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
{title.enabled ? "Watching" : "Paused"}
|
||||||
{#if title.last_checked_at}
|
{#if title.last_checked_at}
|
||||||
· last checked {title.last_checked_at}
|
· {title.last_checked_at}
|
||||||
{/if}
|
{/if}
|
||||||
· watch {title.enabled ? "on" : "off"}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button type="button" class="outline" onclick={onToggleExpand}>
|
<button type="button" class="outline" disabled={busy} onclick={onSync}>Sync</button>
|
||||||
{expanded ? "Hide chapters" : "Show chapters"}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="outline" disabled={busy} onclick={onSync}>
|
|
||||||
Sync now
|
|
||||||
</button>
|
|
||||||
<button type="button" class="outline secondary" onclick={onToggleEnabled}>
|
<button type="button" class="outline secondary" onclick={onToggleEnabled}>
|
||||||
{title.enabled ? "Disable watch" : "Enable watch"}
|
{title.enabled ? "Unwatch" : "Watch"}
|
||||||
</button>
|
|
||||||
<button type="button" class="outline contrast" onclick={onRemove}>
|
|
||||||
Remove
|
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="outline contrast" onclick={onRemove}>Remove</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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}
|
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -4,53 +4,31 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {object} Props
|
* @typedef {object} Props
|
||||||
* @property {any[]} titles
|
* @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 {boolean} busy
|
||||||
* @property {(titleId: number) => void | Promise<void>} onToggleExpand
|
|
||||||
* @property {(titleId: number) => void | Promise<void>} onSync
|
* @property {(titleId: number) => void | Promise<void>} onSync
|
||||||
* @property {(title: any) => void | Promise<void>} onToggleEnabled
|
* @property {(title: any) => void | Promise<void>} onToggleEnabled
|
||||||
* @property {(titleId: number) => void | Promise<void>} onRemove
|
* @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} */
|
/** @type {Props} */
|
||||||
let {
|
let { titles = [], busy = false, onSync, onToggleEnabled, onRemove } = $props();
|
||||||
titles = [],
|
|
||||||
expanded = {},
|
|
||||||
loadingChapters = {},
|
|
||||||
chaptersByTitle = {},
|
|
||||||
busy = false,
|
|
||||||
onToggleExpand,
|
|
||||||
onSync,
|
|
||||||
onToggleEnabled,
|
|
||||||
onRemove,
|
|
||||||
onCancelChapter,
|
|
||||||
onRedownload,
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section class="title-list">
|
||||||
<h2>Titles</h2>
|
<h2>Comics</h2>
|
||||||
{#if titles.length === 0}
|
{#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}
|
{:else}
|
||||||
|
<div class="title-grid">
|
||||||
{#each titles as title (title.id)}
|
{#each titles as title (title.id)}
|
||||||
<TitleCard
|
<TitleCard
|
||||||
{title}
|
{title}
|
||||||
expanded={Boolean(expanded[title.id])}
|
|
||||||
loading={Boolean(loadingChapters[title.id])}
|
|
||||||
chapters={chaptersByTitle[title.id] || []}
|
|
||||||
{busy}
|
{busy}
|
||||||
onToggleExpand={() => onToggleExpand(title.id)}
|
|
||||||
onSync={() => onSync(title.id)}
|
onSync={() => onSync(title.id)}
|
||||||
onToggleEnabled={() => onToggleEnabled(title)}
|
onToggleEnabled={() => onToggleEnabled(title)}
|
||||||
onRemove={() => onRemove(title.id)}
|
onRemove={() => onRemove(title.id)}
|
||||||
{onCancelChapter}
|
|
||||||
{onRedownload}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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