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:
2026-08-03 08:14:15 +07:00
parent 704394169a
commit b08c5acd89
11 changed files with 501 additions and 280 deletions
+42 -269
View File
@@ -1,8 +1,15 @@
<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 jobs = $state([]);
let chaptersByTitle = $state({});
let url = $state("");
let error = $state("");
let busy = $state(false);
let loadingChapters = $state({});
@@ -12,42 +19,19 @@
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 = {}) {
const method = (options.method || "GET").toUpperCase();
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) {
function markUnauthorized() {
if (authRequired) {
authenticated = false;
authUsername = "";
}
if (!res.ok) {
throw new Error(data.error || data.message || res.statusText || "Request failed");
}
return data;
}
/** @param {string} path @param {RequestInit} [options] */
function api(path, options = {}) {
return apiRequest(path, { ...options, onUnauthorized: markUnauthorized });
}
async function checkAuth() {
@@ -58,22 +42,17 @@
authReady = true;
}
async function login(event) {
event.preventDefault();
async function login({ username, password }) {
loginError = "";
loginBusy = true;
try {
const data = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: loginUser.trim(),
password: loginPass,
}),
body: JSON.stringify({ username, password }),
});
authRequired = Boolean(data.authRequired);
authenticated = Boolean(data.authenticated);
authUsername = typeof data.username === "string" ? data.username : loginUser.trim();
loginPass = "";
authUsername = typeof data.username === "string" ? data.username : username;
await refresh();
} catch (err) {
loginError = err instanceof Error ? err.message : String(err);
@@ -124,16 +103,14 @@
}
}
async function addTitle(event) {
event.preventDefault();
async function addTitle(url) {
error = "";
busy = true;
try {
await api("/api/titles", {
method: "POST",
body: JSON.stringify({ url: url.trim() }),
body: JSON.stringify({ url }),
});
url = "";
await refresh();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
@@ -262,236 +239,32 @@
</script>
<main class="app-shell">
<header class="app-header">
<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>
<AppHeader
showLogout={authReady && authRequired && authenticated}
username={authUsername}
onLogout={logout}
/>
{#if !authReady}
<p class="muted">Loading…</p>
{:else if authRequired && !authenticated}
{#if loginError}
<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>
<LoginForm error={loginError} busy={loginBusy} onSubmit={login} />
{:else}
{#if error}
<p class="error-banner" role="alert">{error}</p>
{/if}
<form class="add-title" onsubmit={addTitle}>
<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>
<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>
<ErrorBanner message={error} />
<AddTitleForm {busy} onSubmit={addTitle} />
<JobsStrip {jobs} onClear={clearQueue} onCancel={cancelChapter} />
<TitleList
{titles}
{expanded}
{loadingChapters}
{chaptersByTitle}
{busy}
onToggleExpand={toggleExpand}
onSync={syncTitle}
onToggleEnabled={toggleEnabled}
onRemove={removeTitle}
onCancelChapter={cancelChapter}
onRedownload={redownload}
/>
{/if}
</main>
+32
View File
@@ -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>
+23
View File
@@ -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>
+87
View File
@@ -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>
+13
View File
@@ -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}
+62
View File
@@ -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>
+50
View File
@@ -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>
+99
View File
@@ -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>
+56
View File
@@ -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>
+37
View File
@@ -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;
}