feat (ui): selecteer meerdere episodes tegelijkertijd

This commit is contained in:
kodi
2026-03-10 09:56:15 +01:00
parent 4941b4161c
commit db4c640769
5 changed files with 172 additions and 36 deletions
+107 -23
View File
@@ -7,6 +7,8 @@
selectedSeries: null,
selectedSeriesSummary: null,
episodes: [],
selectedEpisodeIds: new Set(),
episodeSelectionAnchorId: null,
selectedEpisodes: [],
selectedFiles: [],
selectedPairIndex: null,
@@ -63,6 +65,7 @@
rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"),
purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"),
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
addSelectedEpisodesBtn: document.getElementById("addSelectedEpisodesBtn"),
episodesList: document.getElementById("episodesList"),
episodeMeta: document.getElementById("episodeMeta"),
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
@@ -159,14 +162,6 @@
console.log(text);
}
function makeBtn(label, handler, secondary) {
const btn = document.createElement("button");
btn.textContent = label;
if (secondary) btn.className = "secondary";
btn.addEventListener("click", handler);
return btn;
}
function setBusy(btn, busy) {
if (btn) btn.disabled = busy;
}
@@ -653,12 +648,24 @@
out("Search result", data);
}
async function loadEpisodes() {
if (!state.selectedSeries || !state.selectedSeries.id) {
throw new Error("Select a series first");
function episodeKey(episode) {
const id = (episode && episode.id != null) ? String(episode.id).trim() : "";
if (id) return id;
return `${episode.season_number || ""}-${episode.episode_number || ""}-${episode.title || ""}`;
}
const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`);
state.episodes = data.items || [];
function updateAddSelectedEpisodesControl() {
if (!el.addSelectedEpisodesBtn) return;
el.addSelectedEpisodesBtn.disabled = state.selectedEpisodeIds.size === 0;
}
function clearEpisodeSelection() {
state.selectedEpisodeIds = new Set();
state.episodeSelectionAnchorId = null;
renderEpisodesList();
}
function renderEpisodesList() {
el.episodesList.innerHTML = "";
let previousSeasonKey = null;
state.episodes.forEach((episode) => {
@@ -678,7 +685,14 @@
previousSeasonKey = seasonKey;
}
const key = episodeKey(episode);
const li = document.createElement("li");
li.classList.add("episode-row");
if (state.selectedEpisodeIds.has(key)) li.classList.add("selected");
if (state.episodeSelectionAnchorId && state.episodeSelectionAnchorId === key) {
li.classList.add("episode-anchor");
}
const left = document.createElement("span");
left.className = "episode-main";
const title = document.createElement("div");
@@ -694,10 +708,66 @@
dateLine.textContent = `${future ? "Airing" : "Aired"} ${formattedDate}`;
left.appendChild(dateLine);
}
li.appendChild(left);
const right = document.createElement("div");
right.appendChild(makeBtn("Add", async () => {
const payload = {
li.addEventListener("click", (event) => {
handleEpisodeRowClick(key, event);
});
el.episodesList.appendChild(li);
});
updateAddSelectedEpisodesControl();
}
function handleEpisodeRowClick(clickedId, event) {
const clicked = String(clickedId || "").trim();
if (!clicked) return;
const visibleEpisodeIds = (state.episodes || []).map((ep) => episodeKey(ep));
const isShift = !!(event && event.shiftKey);
const isToggle = !!(event && (event.ctrlKey || event.metaKey));
if (isShift) {
const anchor = String(state.episodeSelectionAnchorId || "").trim();
const from = visibleEpisodeIds.indexOf(anchor);
const to = visibleEpisodeIds.indexOf(clicked);
if (from >= 0 && to >= 0) {
const start = Math.min(from, to);
const end = Math.max(from, to);
state.selectedEpisodeIds = new Set(visibleEpisodeIds.slice(start, end + 1));
} else {
state.selectedEpisodeIds = new Set([clicked]);
}
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
return;
}
if (isToggle) {
if (state.selectedEpisodeIds.has(clicked)) {
state.selectedEpisodeIds.delete(clicked);
} else {
state.selectedEpisodeIds.add(clicked);
}
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
return;
}
state.selectedEpisodeIds = new Set([clicked]);
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
}
async function addSelectedEpisodes() {
const selectedIds = new Set(state.selectedEpisodeIds);
if (!selectedIds.size) return;
if (!state.selectedSeries || !state.selectedSeries.name) {
throw new Error("Select a series first");
}
const items = (state.episodes || [])
.filter((episode) => selectedIds.has(episodeKey(episode)))
.map((episode) => ({
id: episode.id,
series: state.selectedSeries.name,
year: state.selectedSeries.year,
@@ -706,18 +776,29 @@
title: episode.title,
aired: episode.aired,
label: episode.label,
};
}));
if (!items.length) return;
await api(q("/api/session/selected-episodes"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [payload] }),
body: JSON.stringify({ items }),
});
clearEpisodeSelection();
await loadSelectedEpisodes();
}));
li.appendChild(left);
li.appendChild(right);
el.episodesList.appendChild(li);
});
}
async function loadEpisodes() {
if (!state.selectedSeries || !state.selectedSeries.id) {
throw new Error("Select a series first");
}
const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`);
state.episodes = data.items || [];
state.selectedEpisodeIds = new Set();
state.episodeSelectionAnchorId = null;
renderEpisodesList();
out("Episodes loaded", data);
}
@@ -1051,6 +1132,9 @@
if (e.target === el.settingsModal) closeSettingsModal();
});
el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn));
el.addSelectedEpisodesBtn.addEventListener("click", () =>
withHandler(addSelectedEpisodes, el.addSelectedEpisodesBtn)
);
el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn));
el.clearSelectedEpisodesBtn.addEventListener("click", () =>
+8
View File
@@ -343,6 +343,10 @@ button.secondary {
min-width: 0;
}
#episodesList li.episode-row {
cursor: pointer;
}
#episodesList .episode-title {
white-space: nowrap;
overflow: hidden;
@@ -359,6 +363,10 @@ button.secondary {
color: var(--button-primary-bg);
}
#episodesList li.episode-row.episode-anchor {
box-shadow: inset 0 0 0 1px var(--button-primary-border);
}
.badge {
display: inline-block;
font-size: 11px;
+2 -1
View File
@@ -69,7 +69,8 @@
</div>
<div class="panel-body">
<div class="row">
<button id="refreshEpisodesBtn">Refresh Episodes</button>
<button id="refreshEpisodesBtn" class="secondary">Refresh Episodes</button>
<button id="addSelectedEpisodesBtn" disabled>Add Selected</button>
</div>
<div class="linked-list-wrap">
<ul id="episodesList" class="list linked-list"></ul>
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8085}"
ALT_BASE_URL="http://host.containers.internal:8085"
detect_base_url() {
if curl -fsS --max-time 2 "$BASE_URL/api/health" >/dev/null 2>&1; then
echo "$BASE_URL"
return
fi
if curl -fsS --max-time 2 "$ALT_BASE_URL/api/health" >/dev/null 2>&1; then
echo "$ALT_BASE_URL"
return
fi
echo "$BASE_URL"
}
BASE_URL="$(detect_base_url)"
echo "Using BASE_URL=$BASE_URL"
echo "== Feature test 1: panel 2 controls include Add Selected and no static row Add button =="
grep -q 'id="addSelectedEpisodesBtn"' app/templates/index.html || { echo "Add Selected button missing"; exit 1; }
grep -q 'id="refreshEpisodesBtn" class="secondary"' app/templates/index.html || { echo "Refresh Episodes should be secondary"; exit 1; }
echo "panel controls validation passed"
echo
echo "== Feature test 2: episode selection modifiers are implemented =="
grep -q "selectedEpisodeIds: new Set()" app/static/app.js || { echo "selectedEpisodeIds state missing"; exit 1; }
grep -q "episodeSelectionAnchorId" app/static/app.js || { echo "episode anchor state missing"; exit 1; }
grep -q "event.shiftKey" app/static/app.js || { echo "shift selection handling missing"; exit 1; }
grep -q "event.ctrlKey || event.metaKey" app/static/app.js || { echo "ctrl/meta toggle handling missing"; exit 1; }
echo "modifier selection validation passed"
echo
echo "== Feature test 3: Add Selected bulk add flow is wired and clears selection =="
grep -q "async function addSelectedEpisodes()" app/static/app.js || { echo "addSelectedEpisodes function missing"; exit 1; }
grep -q "clearEpisodeSelection();" app/static/app.js || { echo "selection clear after add missing"; exit 1; }
grep -q "await loadSelectedEpisodes();" app/static/app.js || { echo "selected episodes reload missing"; exit 1; }
echo "bulk add flow validation passed"
echo
echo "All episode selection feature tests passed."