feat (ui): selecteer meerdere episodes tegelijkertijd
This commit is contained in:
+107
-23
@@ -7,6 +7,8 @@
|
|||||||
selectedSeries: null,
|
selectedSeries: null,
|
||||||
selectedSeriesSummary: null,
|
selectedSeriesSummary: null,
|
||||||
episodes: [],
|
episodes: [],
|
||||||
|
selectedEpisodeIds: new Set(),
|
||||||
|
episodeSelectionAnchorId: null,
|
||||||
selectedEpisodes: [],
|
selectedEpisodes: [],
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
selectedPairIndex: null,
|
selectedPairIndex: null,
|
||||||
@@ -63,6 +65,7 @@
|
|||||||
rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"),
|
rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"),
|
||||||
purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"),
|
purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"),
|
||||||
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
||||||
|
addSelectedEpisodesBtn: document.getElementById("addSelectedEpisodesBtn"),
|
||||||
episodesList: document.getElementById("episodesList"),
|
episodesList: document.getElementById("episodesList"),
|
||||||
episodeMeta: document.getElementById("episodeMeta"),
|
episodeMeta: document.getElementById("episodeMeta"),
|
||||||
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
|
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
|
||||||
@@ -159,14 +162,6 @@
|
|||||||
console.log(text);
|
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) {
|
function setBusy(btn, busy) {
|
||||||
if (btn) btn.disabled = busy;
|
if (btn) btn.disabled = busy;
|
||||||
}
|
}
|
||||||
@@ -653,12 +648,24 @@
|
|||||||
out("Search result", data);
|
out("Search result", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEpisodes() {
|
function episodeKey(episode) {
|
||||||
if (!state.selectedSeries || !state.selectedSeries.id) {
|
const id = (episode && episode.id != null) ? String(episode.id).trim() : "";
|
||||||
throw new Error("Select a series first");
|
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 = "";
|
el.episodesList.innerHTML = "";
|
||||||
let previousSeasonKey = null;
|
let previousSeasonKey = null;
|
||||||
state.episodes.forEach((episode) => {
|
state.episodes.forEach((episode) => {
|
||||||
@@ -678,7 +685,14 @@
|
|||||||
previousSeasonKey = seasonKey;
|
previousSeasonKey = seasonKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = episodeKey(episode);
|
||||||
const li = document.createElement("li");
|
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");
|
const left = document.createElement("span");
|
||||||
left.className = "episode-main";
|
left.className = "episode-main";
|
||||||
const title = document.createElement("div");
|
const title = document.createElement("div");
|
||||||
@@ -694,10 +708,66 @@
|
|||||||
dateLine.textContent = `${future ? "Airing" : "Aired"} ${formattedDate}`;
|
dateLine.textContent = `${future ? "Airing" : "Aired"} ${formattedDate}`;
|
||||||
left.appendChild(dateLine);
|
left.appendChild(dateLine);
|
||||||
}
|
}
|
||||||
|
li.appendChild(left);
|
||||||
|
|
||||||
const right = document.createElement("div");
|
li.addEventListener("click", (event) => {
|
||||||
right.appendChild(makeBtn("Add", async () => {
|
handleEpisodeRowClick(key, event);
|
||||||
const payload = {
|
});
|
||||||
|
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,
|
id: episode.id,
|
||||||
series: state.selectedSeries.name,
|
series: state.selectedSeries.name,
|
||||||
year: state.selectedSeries.year,
|
year: state.selectedSeries.year,
|
||||||
@@ -706,18 +776,29 @@
|
|||||||
title: episode.title,
|
title: episode.title,
|
||||||
aired: episode.aired,
|
aired: episode.aired,
|
||||||
label: episode.label,
|
label: episode.label,
|
||||||
};
|
}));
|
||||||
|
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
await api(q("/api/session/selected-episodes"), {
|
await api(q("/api/session/selected-episodes"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items: [payload] }),
|
body: JSON.stringify({ items }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearEpisodeSelection();
|
||||||
await loadSelectedEpisodes();
|
await loadSelectedEpisodes();
|
||||||
}));
|
}
|
||||||
li.appendChild(left);
|
|
||||||
li.appendChild(right);
|
async function loadEpisodes() {
|
||||||
el.episodesList.appendChild(li);
|
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);
|
out("Episodes loaded", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,6 +1132,9 @@
|
|||||||
if (e.target === el.settingsModal) closeSettingsModal();
|
if (e.target === el.settingsModal) closeSettingsModal();
|
||||||
});
|
});
|
||||||
el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn));
|
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.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn));
|
||||||
el.clearSelectedEpisodesBtn.addEventListener("click", () =>
|
el.clearSelectedEpisodesBtn.addEventListener("click", () =>
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ button.secondary {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#episodesList li.episode-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
#episodesList .episode-title {
|
#episodesList .episode-title {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -359,6 +363,10 @@ button.secondary {
|
|||||||
color: var(--button-primary-bg);
|
color: var(--button-primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#episodesList li.episode-row.episode-anchor {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--button-primary-border);
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -69,7 +69,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="row">
|
<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>
|
||||||
<div class="linked-list-wrap">
|
<div class="linked-list-wrap">
|
||||||
<ul id="episodesList" class="list linked-list"></ul>
|
<ul id="episodesList" class="list linked-list"></ul>
|
||||||
|
|||||||
Binary file not shown.
Executable
+43
@@ -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."
|
||||||
Reference in New Issue
Block a user