Files
2026-06-27 02:34:31 +02:00

1045 lines
46 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>series-select</title>
<style>
/* Color tokens (kept from original) */
@media (prefers-color-scheme: light) {
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.631 0.2357 17.99);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
}
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.631 0.2357 17.99);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
}
/* Shadcn-like component styles (compact, accessible) */
:root {
--radius: 0.625rem;
--gap-sm: 0.5rem;
--gap-xs: 0.25rem;
}
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Inter, Arial, sans-serif;
margin: 0;
padding: 0;
}
.search-container {
margin-bottom: 0.75rem;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: transparent;
color: inherit;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.no-results {
padding: 0.5rem;
color: var(--muted-foreground);
font-size: 0.95rem;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 1.25rem;
background: var(--card);
color: var(--card-foreground);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.heading {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.heading #total-visible {
font-size: 0.9em;
color: var(--muted-foreground);
font-weight: normal;
}
/* Top row to hold title and quick controls */
.top-row {
display: flex;
flex-direction: column;
align-items: start;
justify-content: space-between;
margin-bottom: 0.75rem;
gap: 0.25rem;
}
.top-controls {
display: flex;
gap: 0.5rem;
align-items: end;
}
.top-controls .checkbox-label {
margin: 0;
}
.select-wrapper {
display: flex;
flex-direction: column;
}
.small-note {
font-size: 0.85rem;
color: var(--muted-foreground);
}
.select {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: var(--input);
color: var(--foreground);
font-size: 0.85rem;
cursor: pointer;
}
.select:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.06);
border-color: var(--primary);
}
.select option {
background: var(--card);
color: var(--card-foreground);
}
.series-list {
position: relative;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
height: 70vh;
overflow: auto;
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--input) transparent;
}
/* WebKit (Chrome, Edge, Safari) */
.series-list::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.series-list::-webkit-scrollbar-track {
background: transparent;
}
.series-list::-webkit-scrollbar-thumb {
background: var(--input);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.series-list::-webkit-scrollbar-thumb:hover {
filter: brightness(0.95);
}
.series-item,
.checkbox-label {
display: flex;
align-items: center;
gap: var(--gap-sm);
padding: 0.25rem;
border-radius: 0.5rem;
cursor: pointer;
color: var(--card-foreground);
}
.series-item:hover,
.checkbox-label:hover {
background: var(--input);
}
/* Row layout so toggle sits to the right */
.series-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.toggle-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 0.25rem;
border-radius: 0.375rem;
cursor: pointer;
display: inline-flex;
flex: 1;
align-items: center;
justify-content: end;
transition: color 0.15s ease;
}
.toggle-btn:hover {
color: var(--primary);
}
.toggle-icon {
display: inline-block;
transition: transform 0.15s ease;
transform-origin: center;
}
.toggle-btn[aria-expanded="true"] .toggle-icon {
transform: rotate(90deg);
color: var(--primary);
}
.series-characters {
max-height: 0;
overflow: hidden;
transition:
max-height 0.2s ease,
opacity 0.15s ease;
opacity: 0;
padding-left: 0.5rem;
}
.series-characters.open {
opacity: 1;
max-height: 400px;
}
.popover-panel {
position: absolute;
left: 0;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
border-radius: 0.5rem;
padding: 0.5rem;
z-index: 999;
max-height: 45vh;
overflow: auto;
}
.characters-list {
margin: 0;
padding: 0;
list-style: none;
color: var(--card-foreground);
border-top: 1px solid var(--border);
}
.character-item {
padding: 0.125rem 0;
font-size: 0.95rem;
color: var(--muted-foreground);
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: 0.25rem;
}
.characters-list li {
margin-left: 0;
padding-left: 0;
}
.character-link {
color: var(--muted-foreground);
text-decoration: none;
}
.post-count {
color: var(--primary);
font-size: 0.85rem;
}
.character-link:hover,
.character-link:focus {
color: var(--primary);
text-decoration: underline;
}
/* Popover header/description/search */
.popover-header {
font-weight: 600;
margin-bottom: 0.125rem;
color: var(--card-foreground);
font-size: 0.95rem;
}
.popover-desc {
font-size: 0.85rem;
color: var(--muted-foreground);
margin-bottom: 0.5rem;
}
.popover-search {
width: 100%;
padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: transparent;
color: inherit;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.checkbox,
.char-checkbox {
width: 1rem;
height: 1rem;
accent-color: var(--primary);
border-radius: 0.25rem;
}
.checkbox:focus-visible,
.char-checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.06);
border-color: var(--primary);
}
.label-text,
.series-name {
user-select: none;
color: var(--card-foreground);
max-width: 20rem;
}
.series-counts {
color: var(--muted-foreground);
}
.hidden-count {
color: var(--destructive);
}
.btn {
padding: 0.5rem 0.875rem;
border-radius: var(--radius);
border: 1px solid transparent;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover {
filter: brightness(0.95);
}
.muted {
color: var(--muted-foreground);
font-size: 0.9rem;
}
</style>
<script>
function getInitialStates() {
const urlParams = new URLSearchParams(window.location.search);
return {
showSelectedOnly: urlParams.get("showSelectedOnly") === "true",
sort: urlParams.get("sort") || "alpha",
charSort: urlParams.get("charSort") || "alpha",
};
}
function updateURL() {
const url = new URL(window.location);
url.searchParams.set("showSelectedOnly", SHOW_ONLY_SELECTED.toString());
url.searchParams.set("sort", SORT_MODE);
url.searchParams.set("charSort", CHAR_SORT_MODE);
window.history.replaceState(null, "", url);
}
async function fetchData() {
try {
const [seriesResponse, visibleResponse, hiddenResponse] = await Promise.all([fetch("/sn0w/series"), fetch("/sn0w/visible_series"), fetch("/sn0w/hidden_characters")]);
let seriesData = [];
let visibleData = { visible_series: [] };
let hiddenData = { hidden_characters: [] };
if (seriesResponse.ok) {
try {
seriesData = await seriesResponse.json();
} catch (e) {
console.warn("Failed to parse /sn0w/series JSON", e);
}
} else {
console.warn("/sn0w/series returned status", seriesResponse.status);
}
if (visibleResponse.ok) {
try {
visibleData = await visibleResponse.json();
} catch (e) {
console.warn("Failed to parse /sn0w/visible_series JSON", e);
}
} else {
console.warn("/sn0w/visible_series returned status", visibleResponse.status);
}
if (hiddenResponse.ok) {
try {
hiddenData = await hiddenResponse.json();
} catch (e) {
console.warn("Failed to parse /sn0w/hidden_characters JSON", e);
}
} else {
// This can legitimately be 404 if the file doesn't exist; fall back to empty
console.warn("/sn0w/hidden_characters returned status", hiddenResponse.status);
}
return {
seriesData,
visibleData: visibleData.visible_series || [],
hiddenData: hiddenData.hidden_characters || [],
};
} catch (error) {
console.error("Error fetching data:", error);
return { seriesData: [], visibleData: [], hiddenData: [] };
}
}
function getCharacterPostCount(character) {
if (typeof character === "object" && character?.post_count != null) {
const count = Number(character.post_count);
return Number.isFinite(count) ? count : 0;
}
return 0;
}
function getSeriesPostCount(series) {
return (series.characters || []).reduce((sum, character) => sum + getCharacterPostCount(character), 0);
}
function renderCheckboxes(seriesData, visibleData, hiddenData, filter = "") {
const seriesList = document.getElementById("seriesList");
seriesList.innerHTML = "";
const normalizedFilter = filter.trim().toLowerCase();
// Sort seriesData based on SORT_MODE
if (SORT_MODE === "alpha") {
seriesData.sort((a, b) => a.series.localeCompare(b.series));
} else if (SORT_MODE === "count") {
seriesData.sort((a, b) => b.characters.length - a.characters.length);
} else if (SORT_MODE === "posts") {
seriesData.sort((a, b) => {
const postDiff = getSeriesPostCount(b) - getSeriesPostCount(a);
if (postDiff !== 0) return postDiff;
return a.series.localeCompare(b.series);
});
}
let filtered = seriesData.filter((s) => s.series.toLowerCase().includes(normalizedFilter));
// If the user wants to show only selected, restrict to the persistent VISIBLE_SERIES list
if (SHOW_ONLY_SELECTED) {
filtered = filtered.filter((s) => VISIBLE_SERIES.includes(s.series));
}
// Handles both legacy string characters and new {name, post_count} objects
const getCharName = (c) => (typeof c === "string" ? c : c && c.name ? c.name : "");
const normalizeName = (n) => getCharName(n).replace(/_/g, " ").trim().toLowerCase();
if (filtered.length === 0) {
const empty = document.createElement("div");
empty.className = "no-results";
empty.textContent = "No series match your search.";
seriesList.appendChild(empty);
updateSelectAllState();
return;
}
filtered.forEach((series, idx) => {
const label = document.createElement("label");
label.className = "series-item";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "checkbox";
checkbox.value = series.series;
// If visibleData is empty, default to checked (show all)
// Determine checked state from the persistent VISIBLE_SERIES state (fall back to visibleData if not yet set)
const shouldCheck = Array.isArray(VISIBLE_SERIES) && VISIBLE_SERIES.length > 0 ? VISIBLE_SERIES.includes(series.series) : Array.isArray(visibleData) && visibleData.length > 0 ? visibleData.includes(series.series) : true;
checkbox.checked = shouldCheck;
checkbox.setAttribute("aria-label", series.series);
// Keep the global VISIBLE_SERIES in sync when user toggles checkboxes (so filtering doesn't lose state)
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
if (!VISIBLE_SERIES.includes(series.series)) VISIBLE_SERIES.push(series.series);
} else {
const rem = VISIBLE_SERIES.indexOf(series.series);
if (rem !== -1) VISIBLE_SERIES.splice(rem, 1);
}
updateSelectAllState();
// If we're showing only selected, re-render to reflect removed/added selections
if (SHOW_ONLY_SELECTED) {
const currentFilter = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, currentFilter);
}
});
const nameSpan = document.createElement("span");
nameSpan.className = "series-name";
nameSpan.textContent = series.series.replace(/_/g, " ").trim();
const countSpan = document.createElement("span");
countSpan.className = "series-counts";
const hiddenCount = series.characters.filter((ch) => HIDDEN_CHARACTERS.includes(normalizeName(ch))).length;
const charText = `${series.characters.length} character${series.characters.length !== 1 ? "s" : ""}`;
countSpan.innerHTML = ` (<span>${charText}</span>${hiddenCount > 0 ? `, <span class="hidden-count">${hiddenCount} hidden</span>` : ""})`;
label.appendChild(checkbox);
label.appendChild(nameSpan);
label.appendChild(countSpan);
// Wrapper row: label on the left, toggle on the right
const row = document.createElement("div");
row.className = "series-row";
row.appendChild(label);
// Toggle button to view characters
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "toggle-btn";
const detailsId = `series-characters-${idx}-${Math.random().toString(36).slice(2, 6)}`;
toggleBtn.setAttribute("aria-expanded", "false");
toggleBtn.setAttribute("aria-controls", detailsId);
toggleBtn.title = "View characters";
const icon = document.createElement("span");
icon.className = "toggle-icon";
icon.textContent = "▸";
toggleBtn.appendChild(icon);
row.appendChild(toggleBtn);
seriesList.appendChild(row);
// Toggle behavior: open a floating popover anchored to the row so characters are always visible
toggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isExpanded = toggleBtn.getAttribute("aria-expanded") === "true";
// Close any existing popover
closePopover();
if (isExpanded) {
// Toggling off handled by closePopover
return;
}
// Open new popover
toggleBtn.setAttribute("aria-expanded", "true");
const pop = document.createElement("div");
pop.id = "seriesPopover";
pop.className = "popover-panel";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-modal", "false");
const ul = document.createElement("ul");
ul.className = "characters-list";
// reuse getCharName / normalizeName defined in parent scope
const chars = (series.characters || []).slice();
const renderCharacterList = (filterTerm) => {
ul.innerHTML = "";
const term = (filterTerm || "").trim().toLowerCase();
// If the hide-all list item exists, show it at the top and update its state
if (hideAllLi) {
updateHideAllState(term);
ul.appendChild(hideAllLi);
}
const items = chars.filter((ch) => getCharName(ch).toLowerCase().includes(term));
if (CHAR_SORT_MODE === "alpha") {
items.sort((a, b) => getCharName(a).localeCompare(getCharName(b)));
} else if (CHAR_SORT_MODE === "count") {
items.sort((a, b) => {
const ca = typeof a === "object" && a.post_count != null ? Number(a.post_count) : 0;
const cb = typeof b === "object" && b.post_count != null ? Number(b.post_count) : 0;
if (cb !== ca) return cb - ca; // descending by post_count
return getCharName(a).localeCompare(getCharName(b));
});
}
items.forEach((ch) => {
const li = document.createElement("li");
li.className = "character-item";
const labelChar = document.createElement("label");
labelChar.style.display = "flex";
labelChar.style.alignItems = "center";
labelChar.style.gap = "0.5rem";
const chName = getCharName(ch);
const charCheckbox = document.createElement("input");
charCheckbox.type = "checkbox";
charCheckbox.className = "char-checkbox";
charCheckbox.value = chName;
const normalizedCh = normalizeName(ch);
// Read current state from global HIDDEN_CHARACTERS and invert it for UI (checked = visible)
charCheckbox.checked = !(Array.isArray(HIDDEN_CHARACTERS) && HIDDEN_CHARACTERS.includes(normalizedCh));
const a = document.createElement("a");
a.className = "character-link";
const tagName = encodeURIComponent(chName).replace(/%20/g, "_");
a.href = `https://danbooru.donmai.us/posts?tags=${tagName}+solo`;
a.textContent = chName;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = `Search ${chName} on Danbooru`;
// If hidden, mark visually (unchecked = hidden)
if (!charCheckbox.checked) {
li.classList.add("hidden-character");
}
// When toggled, update HIDDEN_CHARACTERS (global) so selections persist.
// Note: UI is inverted (checked = visible), but API still stores hidden characters.
charCheckbox.addEventListener("change", () => {
const idx = HIDDEN_CHARACTERS.indexOf(normalizedCh);
if (charCheckbox.checked) {
// checked => visible => remove from hidden list
if (idx !== -1) HIDDEN_CHARACTERS.splice(idx, 1);
li.classList.remove("hidden-character");
} else {
// unchecked => hidden => add to hidden list
if (idx === -1) HIDDEN_CHARACTERS.push(normalizedCh);
li.classList.add("hidden-character");
}
// keep hide-all state in sync when individual toggles occur
if (hideAllLi) updateHideAllState(term);
});
labelChar.appendChild(charCheckbox);
labelChar.appendChild(a);
// Append post count badge if provided in object form
if (typeof ch === "object" && ch.post_count != null) {
const postSpan = document.createElement("span");
postSpan.className = "post-count";
postSpan.textContent = ` (${ch.post_count})`;
labelChar.appendChild(postSpan);
}
li.appendChild(labelChar);
ul.appendChild(li);
});
};
// Add a heading and short description to explain the popover
const header = document.createElement("div");
header.className = "popover-header";
header.textContent = "Manage characters — check to show, uncheck to hide.";
header.setAttribute("role", "heading");
header.setAttribute("aria-level", "3");
pop.appendChild(header);
// Provide a hide-all control (rendered inside the characters list) and optional search input for larger lists
let updateHideAllState = () => {};
let hideAllCheckbox = null;
let hideAllLi = null;
if (chars.length > 5) {
// Search input (keeps it above the list but the 'Hide all' entry will be inside the list)
const charSearch = document.createElement("input");
charSearch.type = "search";
charSearch.className = "popover-search";
charSearch.placeholder = "Search characters...";
charSearch.setAttribute("aria-label", "Search characters");
let currentFilter = "";
charSearch.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
renderCharacterList(currentFilter);
updateHideAllState(currentFilter);
});
pop.appendChild(charSearch);
// Create hide-all list item (it will be inserted as the first list item by renderCharacterList)
hideAllLi = document.createElement("li");
hideAllLi.className = "character-item";
// Match structure and classes with regular character rows so styles align
const labelCharAll = document.createElement("label");
labelCharAll.style.display = "flex";
labelCharAll.style.alignItems = "center";
labelCharAll.style.gap = "0.5rem";
hideAllCheckbox = document.createElement("input");
hideAllCheckbox.type = "checkbox";
// use same checkbox class for consistent size/style
hideAllCheckbox.className = "char-checkbox char-hide-all-checkbox";
hideAllCheckbox.id = `${detailsId}-hideall`;
const hideAllText = document.createElement("span");
hideAllText.textContent = "Show filtered";
hideAllText.style.userSelect = "none";
labelCharAll.appendChild(hideAllCheckbox);
labelCharAll.appendChild(hideAllText);
hideAllLi.appendChild(labelCharAll);
// Helper to update hide-all checkbox state based on current filter
updateHideAllState = (filterTerm = "") => {
const term = (filterTerm || "").trim().toLowerCase();
const items = chars.filter((ch) => getCharName(ch).toLowerCase().includes(term));
const normalizedItems = items.map((ch) => normalizeName(ch));
const total = normalizedItems.length;
const hiddenCount = normalizedItems.filter((n) => HIDDEN_CHARACTERS.includes(n)).length;
const visibleCount = total - hiddenCount;
// Checkbox now means "Show filtered" (checked = all visible)
hideAllCheckbox.checked = total > 0 && visibleCount === total;
hideAllCheckbox.indeterminate = visibleCount > 0 && visibleCount < total;
};
// When hide-all (now "Show filtered") toggled, add/remove all filtered items accordingly
hideAllCheckbox.addEventListener("change", () => {
const term = (currentFilter || "").trim().toLowerCase();
const items = chars.filter((ch) => getCharName(ch).toLowerCase().includes(term));
items.forEach((ch) => {
const n = normalizeName(ch);
const idx = HIDDEN_CHARACTERS.indexOf(n);
if (hideAllCheckbox.checked) {
// checked => show all filtered => remove from hidden list
if (idx !== -1) HIDDEN_CHARACTERS.splice(idx, 1);
} else {
// unchecked => hide all filtered => add to hidden list
if (idx === -1) HIDDEN_CHARACTERS.push(n);
}
});
renderCharacterList(currentFilter);
updateHideAllState(currentFilter);
});
// populate list and focus search
renderCharacterList("");
updateHideAllState("");
setTimeout(() => charSearch.focus(), 0);
} else {
// populate list (no search, no hide-all)
renderCharacterList("");
}
pop.appendChild(ul);
// Append and position relative to the list container
seriesList.appendChild(pop);
const top = row.offsetTop + row.offsetHeight;
pop.style.position = "absolute";
pop.style.left = "0";
pop.style.top = top + "px";
pop.style.minWidth = "200px";
pop.style.width = "calc(100% - 1.3rem)";
pop.style.zIndex = "999";
pop.scrollIntoView({ behavior: "smooth", block: "nearest" });
// focus for accessibility
pop.tabIndex = -1;
pop.focus();
});
});
// Calculate total visible characters
const totalVisible = ALL_SERIES.filter((s) => VISIBLE_SERIES.includes(s.series)).reduce((sum, s) => {
const hiddenCount = s.characters.filter((ch) => HIDDEN_CHARACTERS.includes(normalizeName(ch))).length;
return sum + (s.characters.length - hiddenCount);
}, 0);
document.getElementById("total-visible").textContent = `(${totalVisible} characters)`;
updateSelectAllState();
}
let ALL_SERIES = [];
let VISIBLE_SERIES = [];
let HIDDEN_CHARACTERS = [];
let SHOW_ONLY_SELECTED = false;
let SORT_MODE = "alpha";
let CHAR_SORT_MODE = "alpha";
function closePopover() {
const pop = document.getElementById("seriesPopover");
if (pop) pop.remove();
// reset any toggle buttons
document.querySelectorAll('.toggle-btn[aria-expanded="true"]').forEach((btn) => btn.setAttribute("aria-expanded", "false"));
}
// Styles for hidden characters (muted + strike-through)
const style = document.createElement("style");
style.textContent = `
.hidden-character .character-link { color: var(--muted-foreground); text-decoration: line-through; }
`;
document.head.appendChild(style);
// Close popover when clicking outside
document.addEventListener("click", (e) => {
if (!e.target.closest(".popover-panel") && !e.target.closest(".toggle-btn")) {
closePopover();
}
});
// Close popover on ESC
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePopover();
});
function updateSelectAllState() {
const selectAll = document.getElementById("selectAll");
const total = ALL_SERIES.length;
if (total === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
const selectedCount = Array.isArray(VISIBLE_SERIES) ? VISIBLE_SERIES.length : 0;
selectAll.checked = selectedCount === total;
selectAll.indeterminate = selectedCount > 0 && selectedCount < total;
}
function setupSelectAll() {
const selectAll = document.getElementById("selectAll");
selectAll.addEventListener("change", () => {
const checkboxes = document.querySelectorAll("#seriesList .checkbox");
checkboxes.forEach((cb) => (cb.checked = selectAll.checked));
// Update persistent selection state (VISIBLE_SERIES) when using select-all
if (selectAll.checked) {
VISIBLE_SERIES = ALL_SERIES.map((s) => s.series);
} else {
VISIBLE_SERIES = [];
}
// Re-render in case SHOW_ONLY_SELECTED is enabled so the UI updates
const currentFilter = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, currentFilter);
});
document.getElementById("seriesList").addEventListener("change", () => {
updateSelectAllState();
});
}
function setupSearch() {
const input = document.getElementById("seriesSearch");
if (!input) return;
let last = "";
input.addEventListener("input", (e) => {
const value = e.target.value || "";
if (value === last) return;
last = value;
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, value);
});
// Show-only-selected toggle handler
const showOnlyEl = document.getElementById("showSelectedOnly");
if (showOnlyEl) {
showOnlyEl.addEventListener("change", (e) => {
SHOW_ONLY_SELECTED = !!e.target.checked;
updateURL();
const value = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, value);
});
}
// Sort select handler
const sortEl = document.getElementById("sortSelect");
if (sortEl) {
sortEl.addEventListener("change", (e) => {
SORT_MODE = e.target.value;
updateURL();
const value = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, value);
});
}
// Character sort handler
const charSortEl = document.getElementById("charSortSelect");
if (charSortEl) {
charSortEl.addEventListener("change", (e) => {
CHAR_SORT_MODE = e.target.value;
updateURL();
const value = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, value);
});
}
}
async function submitVisibleSeries() {
// Prefer the persistent VISIBLE_SERIES (includes selections made before filtering/searching)
const visibleSeries = Array.isArray(VISIBLE_SERIES) ? VISIBLE_SERIES.slice() : [];
// Use HIDDEN_CHARACTERS (keeps state even when popovers are opened/closed)
const hiddenCharacters = HIDDEN_CHARACTERS.slice();
try {
const [visResp, hidResp] = await Promise.all([
fetch("/sn0w/visible_series", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visible_series: visibleSeries }),
}),
fetch("/sn0w/hidden_characters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden_characters: hiddenCharacters }),
}),
]);
if (visResp.ok && hidResp.ok) {
alert("Visible series and hidden characters updated successfully!");
// Re-render to update counts
const currentFilter = (document.getElementById("seriesSearch")?.value || "").trim();
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS, currentFilter);
} else {
// try to read and show messages from responses
let errMsg = "Error updating:\n";
if (!visResp.ok) errMsg += "- Series update failed\n";
if (!hidResp.ok) errMsg += "- Hidden characters update failed\n";
alert(errMsg);
}
} catch (error) {
console.error("Error submitting data:", error);
alert("Error submitting data.");
}
}
document.addEventListener("DOMContentLoaded", async () => {
const { seriesData, visibleData, hiddenData } = await fetchData();
ALL_SERIES = seriesData || [];
VISIBLE_SERIES = visibleData || [];
HIDDEN_CHARACTERS = hiddenData || [];
const initial = getInitialStates();
SHOW_ONLY_SELECTED = initial.showSelectedOnly;
SORT_MODE = initial.sort;
CHAR_SORT_MODE = initial.charSort;
document.getElementById("showSelectedOnly").checked = SHOW_ONLY_SELECTED;
document.getElementById("sortSelect").value = SORT_MODE;
document.getElementById("charSortSelect").value = CHAR_SORT_MODE;
renderCheckboxes(ALL_SERIES, VISIBLE_SERIES, HIDDEN_CHARACTERS);
setupSelectAll();
setupSearch();
document.getElementById("submit").addEventListener("click", submitVisibleSeries);
});
</script>
</head>
<body>
<div id="root" class="container" role="region" aria-labelledby="series-heading">
<div class="top-row">
<h1 id="series-heading" class="heading">Select Visible Series <span id="total-visible"></span></h1>
<div class="top-controls">
<div class="select-wrapper">
<label for="sortSelect" class="small-note">Sort Series by:</label>
<select id="sortSelect" class="select">
<option value="alpha">Alphabetical</option>
<option value="count">Total Characters</option>
<option value="posts">Total Character Posts</option>
</select>
</div>
<div class="select-wrapper">
<label for="charSortSelect" class="small-note">Sort Characters by:</label>
<select id="charSortSelect" class="select">
<option value="alpha">Alphabetical</option>
<option value="count">Post Count</option>
</select>
</div>
<label class="checkbox-label" for="showSelectedOnly">
<input type="checkbox" id="showSelectedOnly" class="checkbox" />
<span class="label-text">Show only selected</span>
</label>
</div>
</div>
<div class="search-container">
<input id="seriesSearch" class="search-input" type="search" placeholder="Search series..." aria-label="Search series" />
</div>
<label class="checkbox-label" for="selectAll">
<input type="checkbox" id="selectAll" class="checkbox" />
<span class="label-text">Select All</span>
</label>
<div id="seriesList" class="series-list" aria-live="polite" aria-label="Series list">
<!-- Series checkboxes will be populated here -->
</div>
<button id="submit" class="btn btn-primary" type="button">Submit</button>
</div>
</body>
</html>