diff --git a/docker-compose.yml b/docker-compose.yml
index e9522a6..ff1a3e6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,4 @@
services:
- flaresolverr:
- image: ghcr.io/flaresolverr/flaresolverr:latest
- environment:
- LOG_LEVEL: info
- ports:
- - "8191:8191"
- restart: unless-stopped
-
cxyz:
build: .
ports:
@@ -18,7 +10,6 @@ services:
WATCH_CRON: "0 * * * *"
# Each title is eligible again after this many hours (daily).
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.
# AUTH_USERNAME: admin
# AUTH_PASSWORD: change-me
@@ -26,6 +17,4 @@ services:
# SESSION_TTL_HOURS: "168"
volumes:
- ./data:/app/data
- depends_on:
- - flaresolverr
restart: unless-stopped
diff --git a/web/src/App.svelte b/web/src/App.svelte
index ff3343c..5328ccf 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -1,8 +1,15 @@
-
+
{#if !authReady}
Loading…
{:else if authRequired && !authenticated}
- {#if loginError}
- {loginError}
- {/if}
-
+
{:else}
- {#if error}
- {error}
- {/if}
-
-
-
-
-
-
Activity
-
-
- {#if jobs.length === 0}
- No active jobs.
- {:else}
-
- {/if}
-
-
-
- Titles
- {#if titles.length === 0}
- No titles yet. Add a series URL above.
- {:else}
- {#each titles as title (title.id)}
-
-
-
- {title.title}
- {title.source}
-
- {title.downloaded} / {title.total} downloaded
- {#if title.downloading > 0}
- · {title.downloading} downloading
- {/if}
- {#if title.failed > 0}
- · {title.failed} failed
- {/if}
-
-
-
- {title.url}
- {#if title.last_checked_at}
- · last checked {title.last_checked_at}
- {/if}
- · watch {title.enabled ? "on" : "off"}
-
-
-
-
-
-
-
-
-
- {#if expanded[title.id]}
- {#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
- Loading chapters…
- {:else}
-
-
-
-
-
- | Chapter |
- Status |
- |
-
-
-
- {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
-
- | {ch.label} |
-
- {ch.status}
- {#if ch.error}
- {ch.error}
- {/if}
- |
-
-
- {#if ch.status === "downloading"}
-
- {/if}
-
-
- |
-
- {/each}
-
-
-
- {/if}
- {/if}
-
- {/each}
- {/if}
-
+
+
+
+
{/if}
diff --git a/web/src/components/AddTitleForm.svelte b/web/src/components/AddTitleForm.svelte
new file mode 100644
index 0000000..70efffa
--- /dev/null
+++ b/web/src/components/AddTitleForm.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/web/src/components/AppHeader.svelte b/web/src/components/AppHeader.svelte
new file mode 100644
index 0000000..e81b9b5
--- /dev/null
+++ b/web/src/components/AppHeader.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/web/src/components/ChapterList.svelte b/web/src/components/ChapterList.svelte
new file mode 100644
index 0000000..a4940eb
--- /dev/null
+++ b/web/src/components/ChapterList.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+ | Chapter |
+ Status |
+ |
+
+
+
+ {#each chapters as ch (ch.id)}
+
+ | {ch.label} |
+
+ {ch.status}
+ {#if ch.error}
+ {ch.error}
+ {/if}
+ |
+
+
+ {#if ch.status === "downloading"}
+
+ {/if}
+
+
+ |
+
+ {/each}
+
+
+
diff --git a/web/src/components/ErrorBanner.svelte b/web/src/components/ErrorBanner.svelte
new file mode 100644
index 0000000..146624e
--- /dev/null
+++ b/web/src/components/ErrorBanner.svelte
@@ -0,0 +1,13 @@
+
+
+{#if message}
+ {message}
+{/if}
diff --git a/web/src/components/JobsStrip.svelte b/web/src/components/JobsStrip.svelte
new file mode 100644
index 0000000..33a5be4
--- /dev/null
+++ b/web/src/components/JobsStrip.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
Activity
+
+
+ {#if jobs.length === 0}
+ No active jobs.
+ {:else}
+
+ {/if}
+
diff --git a/web/src/components/LoginForm.svelte b/web/src/components/LoginForm.svelte
new file mode 100644
index 0000000..e42c99b
--- /dev/null
+++ b/web/src/components/LoginForm.svelte
@@ -0,0 +1,50 @@
+
+
+{#if error}
+ {error}
+{/if}
+
+
diff --git a/web/src/components/TitleCard.svelte b/web/src/components/TitleCard.svelte
new file mode 100644
index 0000000..aff46da
--- /dev/null
+++ b/web/src/components/TitleCard.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+
+ {title.title}
+ {title.source}
+
+ {title.downloaded} / {title.total} downloaded
+ {#if title.downloading > 0}
+ · {title.downloading} downloading
+ {/if}
+ {#if title.failed > 0}
+ · {title.failed} failed
+ {/if}
+
+
+
+ {title.url}
+ {#if title.last_checked_at}
+ · last checked {title.last_checked_at}
+ {/if}
+ · watch {title.enabled ? "on" : "off"}
+
+
+
+
+
+
+
+
+
+ {#if expanded}
+ {#if loading && chapters.length === 0}
+ Loading chapters…
+ {:else}
+
+ {/if}
+ {/if}
+
diff --git a/web/src/components/TitleList.svelte b/web/src/components/TitleList.svelte
new file mode 100644
index 0000000..6641ffc
--- /dev/null
+++ b/web/src/components/TitleList.svelte
@@ -0,0 +1,56 @@
+
+
+
+ Titles
+ {#if titles.length === 0}
+ No titles yet. Add a series URL above.
+ {:else}
+ {#each titles as title (title.id)}
+ onToggleExpand(title.id)}
+ onSync={() => onSync(title.id)}
+ onToggleEnabled={() => onToggleEnabled(title)}
+ onRemove={() => onRemove(title.id)}
+ {onCancelChapter}
+ {onRedownload}
+ />
+ {/each}
+ {/if}
+
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
new file mode 100644
index 0000000..53fa3b6
--- /dev/null
+++ b/web/src/lib/api.js
@@ -0,0 +1,37 @@
+/**
+ * @param {string} path - API path.
+ * @param {RequestInit & { onUnauthorized?: () => void }} [options] - Fetch options.
+ * @returns {Promise} 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;
+}