Add authentication feature with session management

- Implemented authentication logic in src/server/auth.js, including session token creation and verification.
- Integrated authentication routes for login, logout, and user session retrieval in the Fastify server.
- Updated docker-compose.yml to include optional authentication environment variables.
- Added @fastify/cookie dependency for cookie management in package.json and package-lock.json.
- Enhanced the frontend with login form and session handling in App.svelte, including UI updates for authenticated states.
- Styled authentication components in app.css for better user experience.
This commit is contained in:
2026-08-03 07:50:00 +07:00
parent 3799ad59f8
commit 704394169a
8 changed files with 628 additions and 176 deletions
+5
View File
@@ -19,6 +19,11 @@ services:
# Each title is eligible again after this many hours (daily). # Each title is eligible again after this many hours (daily).
WATCH_TITLE_INTERVAL_HOURS: "24" WATCH_TITLE_INTERVAL_HOURS: "24"
FLARESOLVERR_URL: http://flaresolverr:8191/v1 FLARESOLVERR_URL: http://flaresolverr:8191/v1
# Shared login (optional). When AUTH_PASSWORD is set, the UI and /api require a session.
# AUTH_USERNAME: admin
# AUTH_PASSWORD: change-me
# SESSION_SECRET: long-random-string-at-least-32-chars
# SESSION_TTL_HOURS: "168"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
depends_on: depends_on:
+34
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/static": "^10.1.2", "@fastify/static": "^10.1.2",
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
@@ -327,6 +328,39 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fastify/cookie": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
"integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^2.0.0",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/cookie/node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/error": { "node_modules/@fastify/error": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
+1
View File
@@ -23,6 +23,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/static": "^10.1.2", "@fastify/static": "^10.1.2",
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
+248
View File
@@ -0,0 +1,248 @@
const crypto = require("node:crypto");
const COOKIE_NAME = "session";
const PUBLIC_AUTH_PATHS = new Set([
"GET /api/auth/me",
"POST /api/auth/login",
"POST /api/auth/logout",
]);
/**
* @typedef {object} AuthConfig
* @property {boolean} enabled - Whether AUTH_PASSWORD is set.
* @property {string} username - Expected username (default admin).
* @property {string} password - Expected password when enabled.
* @property {string} secret - HMAC secret for session cookies.
* @property {number} ttlMs - Session lifetime in milliseconds.
*/
/**
* Reads auth settings from the environment.
* @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty).
*/
function loadAuthConfig() {
const password = (process.env.AUTH_PASSWORD || "").trim();
const enabled = password.length > 0;
const username = (process.env.AUTH_USERNAME || "admin").trim() || "admin";
const secret = (process.env.SESSION_SECRET || "").trim();
const ttlHoursRaw = Number(process.env.SESSION_TTL_HOURS);
const ttlHours =
Number.isFinite(ttlHoursRaw) && ttlHoursRaw > 0 ? ttlHoursRaw : 168;
if (enabled && secret.length < 32) {
throw new Error(
"SESSION_SECRET is required when AUTH_PASSWORD is set (min 32 characters).",
);
}
return {
enabled,
username,
password,
secret,
ttlMs: Math.round(ttlHours * 60 * 60 * 1000),
};
}
/**
* @param {string} input - Submitted password.
* @param {string} expected - Configured password.
* @returns {boolean} Whether they match (timing-safe when lengths match).
*/
function verifyPassword(input, expected) {
const a = Buffer.from(String(input), "utf8");
const b = Buffer.from(String(expected), "utf8");
if (a.length !== b.length) {
// Dummy compare so length mismatch still spends hashing time.
crypto.timingSafeEqual(b, b);
return false;
}
return crypto.timingSafeEqual(a, b);
}
/**
* @param {string} username - Session subject.
* @param {string} secret - HMAC secret.
* @param {number} expiresAt - Unix epoch milliseconds.
* @returns {string} Signed cookie value.
*/
function createSessionToken(username, secret, expiresAt) {
const payload = Buffer.from(
JSON.stringify({ u: username, exp: expiresAt }),
"utf8",
).toString("base64url");
const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
/**
* @param {string|undefined} token - Cookie value.
* @param {string} secret - HMAC secret.
* @returns {{ username: string, expiresAt: number }|null} Parsed session or null.
*/
function parseSessionToken(token, secret) {
if (typeof token !== "string" || token.length === 0) {
return null;
}
const dot = token.lastIndexOf(".");
if (dot <= 0) {
return null;
}
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
const sigBuf = Buffer.from(sig);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return null;
}
try {
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
if (
typeof data?.u !== "string" ||
typeof data?.exp !== "number" ||
!Number.isFinite(data.exp)
) {
return null;
}
if (Date.now() >= data.exp) {
return null;
}
return { username: data.u, expiresAt: data.exp };
} catch {
return null;
}
}
/**
* @param {import("fastify").FastifyRequest} request - Incoming request.
* @param {AuthConfig} config - Auth config.
* @returns {{ username: string, expiresAt: number }|null} Session when valid.
*/
function sessionFromRequest(request, config) {
if (!config.enabled) {
return null;
}
const raw = request.cookies?.[COOKIE_NAME];
return parseSessionToken(raw, config.secret);
}
/**
* Cookie options for setting/clearing the session.
* @param {AuthConfig} config - Auth config.
* @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing).
* @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options.
*/
function sessionCookieOptions(config, maxAgeMs) {
/** @type {import("@fastify/cookie").CookieSerializeOptions} */
const opts = {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
};
if (typeof maxAgeMs === "number") {
opts.maxAge = Math.floor(maxAgeMs / 1000);
}
return opts;
}
/**
* Registers auth routes and an onRequest guard for /api/*.
* @param {import("fastify").FastifyInstance} app - Fastify instance (cookie plugin already registered).
* @param {AuthConfig} config - From {@link loadAuthConfig}.
* @returns {void}
*/
function registerAuth(app, config) {
app.decorateRequest("authUser", null);
app.get("/api/auth/me", async (request) => {
if (!config.enabled) {
return { authRequired: false, authenticated: true };
}
const session = sessionFromRequest(request, config);
if (!session) {
return { authRequired: true, authenticated: false };
}
return {
authRequired: true,
authenticated: true,
username: session.username,
};
});
app.post("/api/auth/login", async (request, reply) => {
if (!config.enabled) {
return { ok: true, authRequired: false, authenticated: true };
}
const body = request.body || {};
const username =
typeof body.username === "string" && body.username.trim().length > 0
? body.username.trim()
: config.username;
const password = typeof body.password === "string" ? body.password : "";
const userOk =
username.length === config.username.length &&
crypto.timingSafeEqual(
Buffer.from(username, "utf8"),
Buffer.from(config.username, "utf8"),
);
const passOk = verifyPassword(password, config.password);
if (!userOk || !passOk) {
return reply.code(401).send({ error: "Invalid username or password" });
}
const expiresAt = Date.now() + config.ttlMs;
const token = createSessionToken(config.username, config.secret, expiresAt);
reply.setCookie(COOKIE_NAME, token, sessionCookieOptions(config, config.ttlMs));
return {
ok: true,
authRequired: true,
authenticated: true,
username: config.username,
};
});
app.post("/api/auth/logout", async (_request, reply) => {
reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config));
return { ok: true, authenticated: false };
});
app.addHook("onRequest", async (request, reply) => {
if (!config.enabled) {
return;
}
const urlPath = request.url.split("?")[0];
if (!urlPath.startsWith("/api/")) {
return;
}
const key = `${request.method.toUpperCase()} ${urlPath}`;
if (PUBLIC_AUTH_PATHS.has(key)) {
return;
}
const session = sessionFromRequest(request, config);
if (!session) {
return reply.code(401).send({ error: "Unauthorized" });
}
request.authUser = session.username;
});
}
module.exports = {
COOKIE_NAME,
createSessionToken,
loadAuthConfig,
parseSessionToken,
registerAuth,
sessionCookieOptions,
sessionFromRequest,
verifyPassword,
};
+11
View File
@@ -3,9 +3,12 @@ const fs = require("fs");
const Fastify = require("fastify"); const Fastify = require("fastify");
const fastifyStatic = require("@fastify/static"); const fastifyStatic = require("@fastify/static");
const fastifyCookie = require("@fastify/cookie");
const { openDatabase } = require("./db.js"); const { openDatabase } = require("./db.js");
const { DownloadQueue } = require("./queue.js"); const { DownloadQueue } = require("./queue.js");
const { startWatcher } = require("./watcher.js"); const { startWatcher } = require("./watcher.js");
const { loadAuthConfig, registerAuth } = require("./auth.js");
const routesPlugin = require("./routes.js"); const routesPlugin = require("./routes.js");
const PORT = Number(process.env.PORT) || 3000; const PORT = Number(process.env.PORT) || 3000;
@@ -25,6 +28,7 @@ async function main() {
const db = openDatabase(DATA_DIR); const db = openDatabase(DATA_DIR);
const queue = new DownloadQueue(db); const queue = new DownloadQueue(db);
const authConfig = loadAuthConfig();
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
// Allow POST/PATCH with Content-Type: application/json and an empty body // Allow POST/PATCH with Content-Type: application/json and an empty body
@@ -44,6 +48,8 @@ async function main() {
}, },
); );
await app.register(fastifyCookie);
registerAuth(app, authConfig);
await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR }); await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR });
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
@@ -84,6 +90,11 @@ async function main() {
app.log.info(`Data dir: ${DATA_DIR}`); app.log.info(`Data dir: ${DATA_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(
authConfig.enabled
? `Auth: enabled (user=${authConfig.username})`
: "Auth: disabled (set AUTH_PASSWORD to enable)",
);
} }
main().catch((err) => { main().catch((err) => {
+295 -176
View File
@@ -8,6 +8,15 @@
let loadingChapters = $state({}); let loadingChapters = $state({});
let expanded = $state({}); let expanded = $state({});
let authReady = $state(false);
let authRequired = $state(false);
let authenticated = $state(false);
let authUsername = $state("");
let loginUser = $state("admin");
let loginPass = $state("");
let loginError = $state("");
let loginBusy = $state(false);
async function api(path, options = {}) { async function api(path, options = {}) {
const method = (options.method || "GET").toUpperCase(); const method = (options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) }; const headers = { ...(options.headers || {}) };
@@ -28,14 +37,66 @@
method, method,
headers, headers,
body, body,
credentials: "include",
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.status === 401 && authRequired) {
authenticated = false;
authUsername = "";
}
if (!res.ok) { if (!res.ok) {
throw new Error(data.error || data.message || res.statusText || "Request failed"); throw new Error(data.error || data.message || res.statusText || "Request failed");
} }
return data; return data;
} }
async function checkAuth() {
const data = await api("/api/auth/me");
authRequired = Boolean(data.authRequired);
authenticated = Boolean(data.authenticated);
authUsername = typeof data.username === "string" ? data.username : "";
authReady = true;
}
async function login(event) {
event.preventDefault();
loginError = "";
loginBusy = true;
try {
const data = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: loginUser.trim(),
password: loginPass,
}),
});
authRequired = Boolean(data.authRequired);
authenticated = Boolean(data.authenticated);
authUsername = typeof data.username === "string" ? data.username : loginUser.trim();
loginPass = "";
await refresh();
} catch (err) {
loginError = err instanceof Error ? err.message : String(err);
authenticated = false;
} finally {
loginBusy = false;
}
}
async function logout() {
error = "";
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* still clear local session state */
}
authenticated = false;
authUsername = "";
titles = [];
jobs = [];
chaptersByTitle = {};
}
async function refresh() { async function refresh() {
const [t, j] = await Promise.all([ const [t, j] = await Promise.all([
api("/api/titles"), api("/api/titles"),
@@ -168,14 +229,31 @@
} }
$effect(() => { $effect(() => {
void checkAuth().catch((err) => {
error = err instanceof Error ? err.message : String(err);
authReady = true;
});
});
$effect(() => {
if (!authReady || (authRequired && !authenticated)) {
return;
}
void refresh(); void refresh();
const id = setInterval(() => { const id = setInterval(() => {
void (async () => { void (async () => {
await refresh(); if (authRequired && !authenticated) {
for (const [titleId, isOpen] of Object.entries(expanded)) { return;
if (isOpen) { }
await loadChapters(Number(titleId)); try {
await refresh();
for (const [titleId, isOpen] of Object.entries(expanded)) {
if (isOpen) {
await loadChapters(Number(titleId));
}
} }
} catch {
/* 401 handled in api() */
} }
})(); })();
}, 2000); }, 2000);
@@ -184,195 +262,236 @@
</script> </script>
<main class="app-shell"> <main class="app-shell">
<header> <header class="app-header">
<h1>Watch & Download</h1> <h1>Watch & Download</h1>
{#if authReady && authRequired && authenticated}
<div class="header-auth">
{#if authUsername}
<span class="muted">{authUsername}</span>
{/if}
<button type="button" class="outline contrast" onclick={logout}>Log out</button>
</div>
{/if}
</header> </header>
{#if error} {#if !authReady}
<p class="error-banner" role="alert">{error}</p> <p class="muted">Loading…</p>
{/if} {:else if authRequired && !authenticated}
{#if loginError}
<form class="add-title" onsubmit={addTitle}> <p class="error-banner" role="alert">{loginError}</p>
<label> {/if}
Series URL <form class="login-form" onsubmit={login}>
<input <label>
type="url" Username
bind:value={url} <input
placeholder="" type="text"
required bind:value={loginUser}
disabled={busy} autocomplete="username"
/> required
</label> disabled={loginBusy}
<button type="submit" disabled={busy || !url.trim()}> />
{busy ? "Working…" : "Add & watch"} </label>
</button> <label>
</form> Password
<input
<section class="jobs-strip" aria-live="polite"> type="password"
<div class="section-heading"> bind:value={loginPass}
<h2>Activity</h2> autocomplete="current-password"
<button required
type="button" disabled={loginBusy}
class="outline contrast" />
disabled={jobs.length === 0} </label>
onclick={clearQueue} <button type="submit" disabled={loginBusy || !loginPass}>
> {loginBusy ? "Signing in…" : "Sign in"}
Clear queue
</button> </button>
</div> </form>
{#if jobs.length === 0} {:else}
<p class="muted">No active jobs.</p> {#if error}
{:else} <p class="error-banner" role="alert">{error}</p>
<ul class="job-list">
{#each jobs as job (job.id)}
<li class="job-row">
<div class="job-info">
<strong>{job.status}</strong>
{job.message || job.type}
{#if job.title_name}
<span class="muted">({job.title_name})</span>
{/if}
</div>
{#if job.chapter_id}
<button
type="button"
class="outline contrast job-cancel"
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
>
Cancel
</button>
{/if}
</li>
{/each}
</ul>
{/if} {/if}
</section>
<section> <form class="add-title" onsubmit={addTitle}>
<h2>Titles</h2> <label>
{#if titles.length === 0} Series URL
<p class="muted">No titles yet. Add a series URL above.</p> <input
{:else} type="url"
{#each titles as title (title.id)} bind:value={url}
<article> placeholder=""
<header> required
<div class="title-meta"> disabled={busy}
<strong>{title.title}</strong> />
<span class="muted">{title.source}</span> </label>
<span class="progress-text"> <button type="submit" disabled={busy || !url.trim()}>
{title.downloaded} / {title.total} downloaded {busy ? "Working…" : "Add & watch"}
{#if title.downloading > 0} </button>
· {title.downloading} downloading </form>
<section class="jobs-strip" aria-live="polite">
<div class="section-heading">
<h2>Activity</h2>
<button
type="button"
class="outline contrast"
disabled={jobs.length === 0}
onclick={clearQueue}
>
Clear queue
</button>
</div>
{#if jobs.length === 0}
<p class="muted">No active jobs.</p>
{:else}
<ul class="job-list">
{#each jobs as job (job.id)}
<li class="job-row">
<div class="job-info">
<strong>{job.status}</strong>
{job.message || job.type}
{#if job.title_name}
<span class="muted">({job.title_name})</span>
{/if} {/if}
{#if title.failed > 0} </div>
· {title.failed} failed {#if job.chapter_id}
{/if} <button
</span> type="button"
</div> class="outline contrast job-cancel"
<p class="muted url-line"> onclick={() => cancelChapter(job.chapter_id, job.title_id)}
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a> >
{#if title.last_checked_at} Cancel
· last checked {title.last_checked_at} </button>
{/if} {/if}
· watch {title.enabled ? "on" : "off"} </li>
</p> {/each}
<div class="row-actions"> </ul>
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}> {/if}
{expanded[title.id] ? "Hide chapters" : "Show chapters"} </section>
</button>
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
Sync now
</button>
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
{title.enabled ? "Disable watch" : "Enable watch"}
</button>
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
Remove
</button>
</div>
</header>
{#if expanded[title.id]} <section>
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]} <h2>Titles</h2>
<p class="muted">Loading chapters…</p> {#if titles.length === 0}
{:else} <p class="muted">No titles yet. Add a series URL above.</p>
<ul class="chapter-list"> {:else}
{#each chaptersByTitle[title.id] || [] as ch (ch.id)} {#each titles as title (title.id)}
<li class="chapter-card"> <article>
<div class="chapter-label">{ch.label}</div> <header>
<span class={`status status-${ch.status}`}>{ch.status}</span> <div class="title-meta">
{#if ch.error} <strong>{title.title}</strong>
<div class="muted chapter-error">{ch.error}</div> <span class="muted">{title.source}</span>
{/if} <span class="progress-text">
<div class="chapter-actions"> {title.downloaded} / {title.total} downloaded
{#if ch.status === "downloading"} {#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}
· watch {title.enabled ? "on" : "off"}
</p>
<div class="row-actions">
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
</button>
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
Sync now
</button>
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
{title.enabled ? "Disable watch" : "Enable watch"}
</button>
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
Remove
</button>
</div>
</header>
{#if expanded[title.id]}
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
<p class="muted">Loading chapters…</p>
{:else}
<ul class="chapter-list">
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<li class="chapter-card">
<div class="chapter-label">{ch.label}</div>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted chapter-error">{ch.error}</div>
{/if}
<div class="chapter-actions">
{#if ch.status === "downloading"}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
>
Cancel
</button>
{/if}
<button <button
type="button" type="button"
class="outline contrast" class="outline"
onclick={() => cancelChapter(ch.id, title.id)} onclick={() => redownload(ch.id, title.id)}
> >
Cancel Redownload
</button> </button>
{/if} </div>
<button </li>
type="button" {/each}
class="outline" </ul>
onclick={() => redownload(ch.id, title.id)} <div class="table-wrap">
> <table>
Redownload <thead>
</button>
</div>
</li>
{/each}
</ul>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Chapter</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<tr> <tr>
<td>{ch.label}</td> <th>Chapter</th>
<td> <th>Status</th>
<span class={`status status-${ch.status}`}>{ch.status}</span> <th></th>
{#if ch.error} </tr>
<div class="muted">{ch.error}</div> </thead>
{/if} <tbody>
</td> {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<td> <tr>
<div class="chapter-actions"> <td>{ch.label}</td>
{#if ch.status === "downloading"} <td>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted">{ch.error}</div>
{/if}
</td>
<td>
<div class="chapter-actions">
{#if ch.status === "downloading"}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
>
Cancel
</button>
{/if}
<button <button
type="button" type="button"
class="outline contrast" class="outline"
onclick={() => cancelChapter(ch.id, title.id)} onclick={() => redownload(ch.id, title.id)}
> >
Cancel Redownload
</button> </button>
{/if} </div>
<button </td>
type="button" </tr>
class="outline" {/each}
onclick={() => redownload(ch.id, title.id)} </tbody>
> </table>
Redownload </div>
</button> {/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if} {/if}
{/if} </article>
</article> {/each}
{/each} {/if}
{/if} </section>
</section> {/if}
</main> </main>
+34
View File
@@ -39,6 +39,40 @@ h1 {
font-size: clamp(1.5rem, 5vw, 2rem); font-size: clamp(1.5rem, 5vw, 2rem);
line-height: 1.2; line-height: 1.2;
overflow-wrap: anywhere; overflow-wrap: anywhere;
margin-bottom: 0;
}
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
margin-bottom: 1.25rem;
}
.header-auth {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.header-auth button {
width: auto;
margin-bottom: 0;
}
form.login-form {
display: grid;
gap: 0.75rem;
max-width: 22rem;
margin-block: 1rem 2rem;
}
form.login-form button {
width: 100%;
margin-bottom: 0;
} }
.muted { .muted {