diff --git a/app/static/app.js b/app/static/app.js index ff8704f..f3639b3 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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"); - } - const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`); - state.episodes = data.items || []; + 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 || ""}`; + } + + 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,30 +708,97 @@ dateLine.textContent = `${future ? "Airing" : "Aired"} ${formattedDate}`; left.appendChild(dateLine); } - - const right = document.createElement("div"); - right.appendChild(makeBtn("Add", async () => { - const payload = { - id: episode.id, - series: state.selectedSeries.name, - year: state.selectedSeries.year, - season_number: episode.season_number, - episode_number: episode.episode_number, - title: episode.title, - aired: episode.aired, - label: episode.label, - }; - await api(q("/api/session/selected-episodes"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: [payload] }), - }); - await loadSelectedEpisodes(); - })); li.appendChild(left); - li.appendChild(right); + + 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, + season_number: episode.season_number, + episode_number: episode.episode_number, + 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 }), + }); + + clearEpisodeSelection(); + await loadSelectedEpisodes(); + } + + 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", () => diff --git a/app/static/styles.css b/app/static/styles.css index 4198750..2e30313 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; diff --git a/app/templates/index.html b/app/templates/index.html index 36127af..e336791 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -69,7 +69,8 @@
- + +
diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index bfdbd51..4c53bee 100644 Binary files a/data/session_state.sqlite3 and b/data/session_state.sqlite3 differ diff --git a/feature_tests_episode_selection.sh b/feature_tests_episode_selection.sh new file mode 100755 index 0000000..a06f981 --- /dev/null +++ b/feature_tests_episode_selection.sh @@ -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."