feat (ui): selecteer meerdere episodes tegelijkertijd
This commit is contained in:
+107
-23
@@ -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", () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
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