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:
2026-08-22 21:39:59 +07:00
parent a92e8e2775
commit 6e7f1ced35
16 changed files with 954 additions and 175 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+42
View File
@@ -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,
+157 -56
View File
@@ -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) {
try { loadingChapters = true;
const data = await api(`/api/titles/${titleId}/chapters`);
chaptersByTitle = { ...chaptersByTitle, [titleId]: data.chapters || [] };
} finally {
loadingChapters = { ...loadingChapters, [titleId]: false };
} }
} try {
const [t, c] = await Promise.all([
async function toggleExpand(titleId) { api(`/api/titles/${titleId}`),
const next = !expanded[titleId]; api(`/api/titles/${titleId}/chapters`),
expanded = { ...expanded, [titleId]: next }; ]);
if (next && !chaptersByTitle[titleId]) { selectedTitle = t.title || null;
await loadChapters(titleId); 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) { 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" />
<AddTitleForm {busy} onSubmit={addTitle} /> <ErrorBanner message={error} tone="error" />
{#if route.name === "home"}
<AddTitleForm {busy} onSubmit={addTitle} />
{/if}
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} /> <JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
<TitleList {#if route.name === "title"}
{titles} <TitlePage
{expanded} title={selectedTitle}
{loadingChapters} loading={loadingChapters}
{chaptersByTitle} {chapters}
{busy} {busy}
onToggleExpand={toggleExpand} {transferBusy}
onSync={syncTitle} onBack={() => navigate("/")}
onToggleEnabled={toggleEnabled} onSync={() => selectedTitle && syncTitle(selectedTitle.id)}
onRemove={removeTitle} onToggleEnabled={() => selectedTitle && toggleEnabled(selectedTitle)}
onCancelChapter={cancelChapter} onRemove={() => selectedTitle && removeTitle(selectedTitle.id)}
onRedownload={redownload} onCancelChapter={cancelChapter}
/> onRedownload={redownload}
onTransfer={transferChapters}
/>
{:else}
<TitleList
{titles}
{busy}
onSync={syncTitle}
onToggleEnabled={toggleEnabled}
onRemove={removeTitle}
/>
{/if}
{/if} {/if}
</main> </main>
+273 -18
View File
@@ -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;
} }
+2 -2
View File
@@ -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>
+12 -1
View File
@@ -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}
+68 -3
View File
@@ -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">
<span class={`status status-${ch.status}`}>{ch.status}</span> <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} {#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>
+6 -3
View File
@@ -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}
+2 -4
View File
@@ -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}
+38 -55
View File
@@ -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>
</header> </div>
{#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>
+6 -28
View File
@@ -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>
+208
View File
@@ -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>
+34
View File
@@ -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"));
}