1045 lines
46 KiB
HTML
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>
|