Refactor frontend components and remove FlareSolverr service from Docker setup
- Removed FlareSolverr service from docker-compose.yml and its related environment variables. - Refactored App.svelte to utilize new components for header, login form, and title management. - Introduced new components: AppHeader, LoginForm, AddTitleForm, ChapterList, TitleCard, TitleList, JobsStrip, and ErrorBanner for better modularity and maintainability. - Updated API handling in App.svelte to streamline authentication and title addition processes. - Enhanced user interface with improved error handling and loading states across components.
This commit is contained in:
@@ -1,12 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
flaresolverr:
|
|
||||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
environment:
|
|
||||||
LOG_LEVEL: info
|
|
||||||
ports:
|
|
||||||
- "8191:8191"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
cxyz:
|
cxyz:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
@@ -18,7 +10,6 @@ services:
|
|||||||
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).
|
||||||
WATCH_TITLE_INTERVAL_HOURS: "24"
|
WATCH_TITLE_INTERVAL_HOURS: "24"
|
||||||
FLARESOLVERR_URL: http://flaresolverr:8191/v1
|
|
||||||
# Shared login (optional). When AUTH_PASSWORD is set, the UI and /api require a session.
|
# Shared login (optional). When AUTH_PASSWORD is set, the UI and /api require a session.
|
||||||
# AUTH_USERNAME: admin
|
# AUTH_USERNAME: admin
|
||||||
# AUTH_PASSWORD: change-me
|
# AUTH_PASSWORD: change-me
|
||||||
@@ -26,6 +17,4 @@ services:
|
|||||||
# SESSION_TTL_HOURS: "168"
|
# SESSION_TTL_HOURS: "168"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
depends_on:
|
|
||||||
- flaresolverr
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+42
-269
@@ -1,8 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { api as apiRequest } from "./lib/api.js";
|
||||||
|
import AppHeader from "./components/AppHeader.svelte";
|
||||||
|
import LoginForm from "./components/LoginForm.svelte";
|
||||||
|
import ErrorBanner from "./components/ErrorBanner.svelte";
|
||||||
|
import AddTitleForm from "./components/AddTitleForm.svelte";
|
||||||
|
import JobsStrip from "./components/JobsStrip.svelte";
|
||||||
|
import TitleList from "./components/TitleList.svelte";
|
||||||
|
|
||||||
let titles = $state([]);
|
let titles = $state([]);
|
||||||
let jobs = $state([]);
|
let jobs = $state([]);
|
||||||
let chaptersByTitle = $state({});
|
let chaptersByTitle = $state({});
|
||||||
let url = $state("");
|
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let loadingChapters = $state({});
|
let loadingChapters = $state({});
|
||||||
@@ -12,42 +19,19 @@
|
|||||||
let authRequired = $state(false);
|
let authRequired = $state(false);
|
||||||
let authenticated = $state(false);
|
let authenticated = $state(false);
|
||||||
let authUsername = $state("");
|
let authUsername = $state("");
|
||||||
let loginUser = $state("admin");
|
|
||||||
let loginPass = $state("");
|
|
||||||
let loginError = $state("");
|
let loginError = $state("");
|
||||||
let loginBusy = $state(false);
|
let loginBusy = $state(false);
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
function markUnauthorized() {
|
||||||
const method = (options.method || "GET").toUpperCase();
|
if (authRequired) {
|
||||||
const headers = { ...(options.headers || {}) };
|
|
||||||
let body = options.body;
|
|
||||||
|
|
||||||
if (body !== undefined && body !== null) {
|
|
||||||
if (!headers["Content-Type"]) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
} else if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
||||||
// Fastify rejects Content-Type: application/json with an empty body
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
body = "{}";
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(path, {
|
|
||||||
...options,
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (res.status === 401 && authRequired) {
|
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
authUsername = "";
|
authUsername = "";
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
}
|
||||||
throw new Error(data.error || data.message || res.statusText || "Request failed");
|
|
||||||
}
|
/** @param {string} path @param {RequestInit} [options] */
|
||||||
return data;
|
function api(path, options = {}) {
|
||||||
|
return apiRequest(path, { ...options, onUnauthorized: markUnauthorized });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
@@ -58,22 +42,17 @@
|
|||||||
authReady = true;
|
authReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(event) {
|
async function login({ username, password }) {
|
||||||
event.preventDefault();
|
|
||||||
loginError = "";
|
loginError = "";
|
||||||
loginBusy = true;
|
loginBusy = true;
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/auth/login", {
|
const data = await api("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, password }),
|
||||||
username: loginUser.trim(),
|
|
||||||
password: loginPass,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
authRequired = Boolean(data.authRequired);
|
authRequired = Boolean(data.authRequired);
|
||||||
authenticated = Boolean(data.authenticated);
|
authenticated = Boolean(data.authenticated);
|
||||||
authUsername = typeof data.username === "string" ? data.username : loginUser.trim();
|
authUsername = typeof data.username === "string" ? data.username : username;
|
||||||
loginPass = "";
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loginError = err instanceof Error ? err.message : String(err);
|
loginError = err instanceof Error ? err.message : String(err);
|
||||||
@@ -124,16 +103,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTitle(event) {
|
async function addTitle(url) {
|
||||||
event.preventDefault();
|
|
||||||
error = "";
|
error = "";
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await api("/api/titles", {
|
await api("/api/titles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ url: url.trim() }),
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
url = "";
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -262,236 +239,32 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header class="app-header">
|
<AppHeader
|
||||||
<h1>Watch & Download</h1>
|
showLogout={authReady && authRequired && authenticated}
|
||||||
{#if authReady && authRequired && authenticated}
|
username={authUsername}
|
||||||
<div class="header-auth">
|
onLogout={logout}
|
||||||
{#if authUsername}
|
/>
|
||||||
<span class="muted">{authUsername}</span>
|
|
||||||
{/if}
|
|
||||||
<button type="button" class="outline contrast" onclick={logout}>Log out</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if !authReady}
|
{#if !authReady}
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{:else if authRequired && !authenticated}
|
{:else if authRequired && !authenticated}
|
||||||
{#if loginError}
|
<LoginForm error={loginError} busy={loginBusy} onSubmit={login} />
|
||||||
<p class="error-banner" role="alert">{loginError}</p>
|
|
||||||
{/if}
|
|
||||||
<form class="login-form" onsubmit={login}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={loginUser}
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
disabled={loginBusy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
bind:value={loginPass}
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
disabled={loginBusy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={loginBusy || !loginPass}>
|
|
||||||
{loginBusy ? "Signing in…" : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if error}
|
<ErrorBanner message={error} />
|
||||||
<p class="error-banner" role="alert">{error}</p>
|
<AddTitleForm {busy} onSubmit={addTitle} />
|
||||||
{/if}
|
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
|
||||||
|
<TitleList
|
||||||
<form class="add-title" onsubmit={addTitle}>
|
{titles}
|
||||||
<label>
|
{expanded}
|
||||||
Series URL
|
{loadingChapters}
|
||||||
<input
|
{chaptersByTitle}
|
||||||
type="url"
|
{busy}
|
||||||
bind:value={url}
|
onToggleExpand={toggleExpand}
|
||||||
placeholder=""
|
onSync={syncTitle}
|
||||||
required
|
onToggleEnabled={toggleEnabled}
|
||||||
disabled={busy}
|
onRemove={removeTitle}
|
||||||
/>
|
onCancelChapter={cancelChapter}
|
||||||
</label>
|
onRedownload={redownload}
|
||||||
<button type="submit" disabled={busy || !url.trim()}>
|
/>
|
||||||
{busy ? "Working…" : "Add & watch"}
|
|
||||||
</button>
|
|
||||||
</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}
|
|
||||||
</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}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Titles</h2>
|
|
||||||
{#if titles.length === 0}
|
|
||||||
<p class="muted">No titles yet. Add a series URL above.</p>
|
|
||||||
{:else}
|
|
||||||
{#each titles as title (title.id)}
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<div class="title-meta">
|
|
||||||
<strong>{title.title}</strong>
|
|
||||||
<span class="muted">{title.source}</span>
|
|
||||||
<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}
|
|
||||||
· 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
|
|
||||||
type="button"
|
|
||||||
class="outline"
|
|
||||||
onclick={() => redownload(ch.id, title.id)}
|
|
||||||
>
|
|
||||||
Redownload
|
|
||||||
</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>
|
|
||||||
<td>{ch.label}</td>
|
|
||||||
<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
|
|
||||||
type="button"
|
|
||||||
class="outline"
|
|
||||||
onclick={() => redownload(ch.id, title.id)}
|
|
||||||
>
|
|
||||||
Redownload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {boolean} [busy]
|
||||||
|
* @property {(url: string) => void | Promise<void>} onSubmit
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { busy = false, onSubmit } = $props();
|
||||||
|
|
||||||
|
let url = $state("");
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = url.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit(value);
|
||||||
|
url = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="add-title" onsubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Series URL
|
||||||
|
<input type="url" bind:value={url} placeholder="" required disabled={busy} />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !url.trim()}>
|
||||||
|
{busy ? "Working…" : "Add & watch"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {boolean} showLogout
|
||||||
|
* @property {string} [username]
|
||||||
|
* @property {() => void} [onLogout]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { showLogout = false, username = "", onLogout } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Watch & Download</h1>
|
||||||
|
{#if showLogout}
|
||||||
|
<div class="header-auth">
|
||||||
|
{#if username}
|
||||||
|
<span class="muted">{username}</span>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="outline contrast" onclick={onLogout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Chapter
|
||||||
|
* @property {number} id
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} status
|
||||||
|
* @property {string} [error]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {Chapter[]} chapters
|
||||||
|
* @property {number} titleId
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancel
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { chapters = [], titleId, onCancel, onRedownload } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="chapter-list">
|
||||||
|
{#each chapters 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={() => onCancel(ch.id, titleId)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="outline" onclick={() => onRedownload(ch.id, titleId)}>
|
||||||
|
Redownload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Chapter</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each chapters as ch (ch.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{ch.label}</td>
|
||||||
|
<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={() => onCancel(ch.id, titleId)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="outline" onclick={() => onRedownload(ch.id, titleId)}>
|
||||||
|
Redownload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {string} message
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { message } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<p class="error-banner" role="alert">{message}</p>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Job
|
||||||
|
* @property {number} id
|
||||||
|
* @property {string} status
|
||||||
|
* @property {string} [message]
|
||||||
|
* @property {string} [type]
|
||||||
|
* @property {string} [title_name]
|
||||||
|
* @property {number} [chapter_id]
|
||||||
|
* @property {number} [title_id]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {Job[]} jobs
|
||||||
|
* @property {() => void | Promise<void>} onClear
|
||||||
|
* @property {(chapterId: number, titleId: number|undefined) => void | Promise<void>} onCancel
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { jobs = [], onClear, onCancel } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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={onClear}
|
||||||
|
>
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
{#if job.chapter_id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outline contrast job-cancel"
|
||||||
|
onclick={() => onCancel(job.chapter_id, job.title_id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {string} [error]
|
||||||
|
* @property {boolean} [busy]
|
||||||
|
* @property {(payload: { username: string, password: string }) => void | Promise<void>} onSubmit
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { error = "", busy = false, onSubmit } = $props();
|
||||||
|
|
||||||
|
let username = $state("admin");
|
||||||
|
let password = $state("");
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onSubmit({ username: username.trim(), password });
|
||||||
|
password = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error-banner" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="login-form" onsubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={username}
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !password}>
|
||||||
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script>
|
||||||
|
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} title
|
||||||
|
* @property {boolean} expanded
|
||||||
|
* @property {boolean} loading
|
||||||
|
* @property {any[]} chapters
|
||||||
|
* @property {boolean} busy
|
||||||
|
* @property {() => void | Promise<void>} onToggleExpand
|
||||||
|
* @property {() => void | Promise<void>} onSync
|
||||||
|
* @property {() => void | Promise<void>} onToggleEnabled
|
||||||
|
* @property {() => void | Promise<void>} onRemove
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancelChapter
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
expanded = false,
|
||||||
|
loading = false,
|
||||||
|
chapters = [],
|
||||||
|
busy = false,
|
||||||
|
onToggleExpand,
|
||||||
|
onSync,
|
||||||
|
onToggleEnabled,
|
||||||
|
onRemove,
|
||||||
|
onCancelChapter,
|
||||||
|
onRedownload,
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<div class="title-meta">
|
||||||
|
<strong>{title.title}</strong>
|
||||||
|
<span class="muted">{title.source}</span>
|
||||||
|
<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}
|
||||||
|
· watch {title.enabled ? "on" : "off"}
|
||||||
|
</p>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="outline" onclick={onToggleExpand}>
|
||||||
|
{expanded ? "Hide chapters" : "Show chapters"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline" disabled={busy} onclick={onSync}>
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline secondary" onclick={onToggleEnabled}>
|
||||||
|
{title.enabled ? "Disable watch" : "Enable watch"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline contrast" onclick={onRemove}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
{#if loading && chapters.length === 0}
|
||||||
|
<p class="muted">Loading chapters…</p>
|
||||||
|
{:else}
|
||||||
|
<ChapterList
|
||||||
|
{chapters}
|
||||||
|
titleId={title.id}
|
||||||
|
onCancel={onCancelChapter}
|
||||||
|
{onRedownload}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script>
|
||||||
|
import TitleCard from "./TitleCard.svelte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Props
|
||||||
|
* @property {any[]} titles
|
||||||
|
* @property {Record<string|number, boolean>} expanded
|
||||||
|
* @property {Record<string|number, boolean>} loadingChapters
|
||||||
|
* @property {Record<string|number, any[]>} chaptersByTitle
|
||||||
|
* @property {boolean} busy
|
||||||
|
* @property {(titleId: number) => void | Promise<void>} onToggleExpand
|
||||||
|
* @property {(titleId: number) => void | Promise<void>} onSync
|
||||||
|
* @property {(title: any) => void | Promise<void>} onToggleEnabled
|
||||||
|
* @property {(titleId: number) => void | Promise<void>} onRemove
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onCancelChapter
|
||||||
|
* @property {(chapterId: number, titleId: number) => void | Promise<void>} onRedownload
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let {
|
||||||
|
titles = [],
|
||||||
|
expanded = {},
|
||||||
|
loadingChapters = {},
|
||||||
|
chaptersByTitle = {},
|
||||||
|
busy = false,
|
||||||
|
onToggleExpand,
|
||||||
|
onSync,
|
||||||
|
onToggleEnabled,
|
||||||
|
onRemove,
|
||||||
|
onCancelChapter,
|
||||||
|
onRedownload,
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Titles</h2>
|
||||||
|
{#if titles.length === 0}
|
||||||
|
<p class="muted">No titles yet. Add a series URL above.</p>
|
||||||
|
{:else}
|
||||||
|
{#each titles as title (title.id)}
|
||||||
|
<TitleCard
|
||||||
|
{title}
|
||||||
|
expanded={Boolean(expanded[title.id])}
|
||||||
|
loading={Boolean(loadingChapters[title.id])}
|
||||||
|
chapters={chaptersByTitle[title.id] || []}
|
||||||
|
{busy}
|
||||||
|
onToggleExpand={() => onToggleExpand(title.id)}
|
||||||
|
onSync={() => onSync(title.id)}
|
||||||
|
onToggleEnabled={() => onToggleEnabled(title)}
|
||||||
|
onRemove={() => onRemove(title.id)}
|
||||||
|
{onCancelChapter}
|
||||||
|
{onRedownload}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @param {string} path - API path.
|
||||||
|
* @param {RequestInit & { onUnauthorized?: () => void }} [options] - Fetch options.
|
||||||
|
* @returns {Promise<any>} Parsed JSON body.
|
||||||
|
*/
|
||||||
|
export async function api(path, options = {}) {
|
||||||
|
const { onUnauthorized, ...fetchOptions } = options;
|
||||||
|
const method = (fetchOptions.method || "GET").toUpperCase();
|
||||||
|
const headers = { ...(fetchOptions.headers || {}) };
|
||||||
|
let body = fetchOptions.body;
|
||||||
|
|
||||||
|
if (body !== undefined && body !== null) {
|
||||||
|
if (!headers["Content-Type"]) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
} else if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||||
|
// Fastify rejects Content-Type: application/json with an empty body
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...fetchOptions,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 401 && typeof onUnauthorized === "function") {
|
||||||
|
onUnauthorized();
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || data.message || res.statusText || "Request failed");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user