fix(web): add series library selector
ci / web (push) Successful in 1m20s
e2e / e2e (push) Successful in 2m8s
ci / rust (push) Failing after 14m52s

This commit is contained in:
Miguel Palhas
2026-08-25 14:24:30 +01:00
parent bd5b00b14a
commit 8947febbff
3 changed files with 75 additions and 3 deletions
+9 -3
View File
@@ -499,14 +499,20 @@
</div>
</section>
<!-- removal is the one library control a series carries (issue 175):
the same arming panel a movie uses — evidence first, then the
decision — wired to DELETE /api/series/{id} -->
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<label class="movie-root-field">
<span class="field-label readout dim" id="series-root-label">root</span>
<select
class="form-input movie-root"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<span class="movie-controls-space"></span>
<button
type="button"
+46
View File
@@ -107,6 +107,7 @@ import {
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
updateSeries,
waiveAndGrabTv,
} from "./series";
import { settingsMain } from "./settings";
@@ -3316,6 +3317,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const actionsEl = must<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const rootSelect = must<HTMLSelectElement>("#series-root");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#series-remove-panel");
@@ -3431,6 +3433,49 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
}
function paintRootControl() {
const current = series;
if (!current) {
return;
}
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "tv")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
if (rootSelect.value !== String(current.root_id)) {
const option = document.createElement("option");
option.value = String(current.root_id);
option.textContent = `root ${current.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(current.root_id);
}
rootSelect.addEventListener("change", () => {
const current = series;
if (!current) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === current.root_id) {
return;
}
rootSelect.disabled = true;
void updateSeries(current.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(current.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
focusKey = "series-root";
void load();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
@@ -3984,6 +4029,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
: [],
);
paintHeader();
paintRootControl();
clearRichDetail();
renderSeasons();
syncRefreshWatch(ticket);
+20
View File
@@ -72,6 +72,26 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
}
}
/** Move a series to another TV root through its partial update endpoint. */
export async function updateSeries(
seriesId: number,
patch: { root_id: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
try {