Add filters
Django CI/CD / test (push) Successful in 43s
Django CI/CD / build-and-push (push) Successful in 1m22s

This commit is contained in:
2026-06-06 12:13:04 +02:00
parent 36b1382015
commit b6864e59ce
17 changed files with 2743 additions and 44 deletions
+161 -2
View File
@@ -826,6 +826,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
.top-3 {
top: calc(var(--spacing) * 3);
}
@@ -1273,6 +1276,9 @@
margin-left: -10px !important;
}
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
.ml-auto {
margin-left: auto;
}
@@ -1431,6 +1437,9 @@
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
.h-2 {
height: calc(var(--spacing) * 2);
}
.h-2\.5 {
height: calc(var(--spacing) * 2.5);
}
@@ -1461,9 +1470,15 @@
.h-full {
height: 100%;
}
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-full {
max-height: 100%;
}
.min-h-\[28px\] {
min-height: 28px;
}
.min-h-screen {
min-height: 100vh;
}
@@ -1656,6 +1671,10 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-full {
--tw-translate-y: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1694,6 +1713,12 @@
.list-disc {
list-style-type: disc;
}
.appearance-none {
appearance: none;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1826,6 +1851,9 @@
.rounded-base {
border-radius: var(--radius-base);
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
@@ -1888,6 +1916,10 @@
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-e {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px;
@@ -1984,9 +2016,15 @@
.border-red-200 {
border-color: var(--color-red-200);
}
.border-red-500 {
border-color: var(--color-red-500);
}
.border-transparent {
border-color: transparent;
}
.border-white {
border-color: var(--color-white);
}
.apexcharts-active {
.apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group {
padding: 0 !important;
@@ -2090,6 +2128,12 @@
.bg-neutral-secondary-medium {
background-color: var(--color-neutral-secondary-medium);
}
.bg-neutral-secondary-medium\/50 {
background-color: color-mix(in srgb, oklch(98.5% 0.002 247.839) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-neutral-secondary-medium) 50%, transparent);
}
}
.bg-neutral-tertiary-medium {
background-color: var(--color-neutral-tertiary-medium);
}
@@ -2287,6 +2331,9 @@
color: heading !important;
}
}
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-16 {
padding-bottom: calc(var(--spacing) * 16);
}
@@ -2446,6 +2493,10 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
.tracking-wide {
--tw-tracking: var(--tracking-wide);
letter-spacing: var(--tracking-wide);
}
.text-balance {
text-wrap: balance;
}
@@ -2500,6 +2551,9 @@
.text-body {
color: var(--color-body);
}
.text-brand {
color: var(--color-brand);
}
.text-fg-brand {
color: var(--color-fg-brand);
}
@@ -2569,6 +2623,9 @@
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2663,6 +2720,10 @@
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.\[program\:caddy\] {
program: caddy;
}
@@ -2792,6 +2853,16 @@
background-color: var(--color-gray-50);
}
}
.hover\:scale-110 {
&:hover {
@media (hover: hover) {
--tw-scale-x: 110%;
--tw-scale-y: 110%;
--tw-scale-z: 110%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
}
}
.hover\:cursor-pointer {
&:hover {
@media (hover: hover) {
@@ -2862,6 +2933,13 @@
}
}
}
.hover\:bg-neutral-secondary-medium {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-medium);
}
}
}
.hover\:bg-neutral-tertiary-medium {
&:hover {
@media (hover: hover) {
@@ -2967,6 +3045,13 @@
}
}
}
.hover\:text-red-700 {
&:hover {
@media (hover: hover) {
color: var(--color-red-700);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -2989,6 +3074,12 @@
color: var(--color-blue-700);
}
}
.focus\:ring-0 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -3082,9 +3173,9 @@
max-width: var(--container-xl);
}
}
.sm\:rounded-lg {
.sm\:grid-cols-2 {
@media (width >= 40rem) {
border-radius: var(--radius-lg);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:rounded-t-lg {
@@ -3232,6 +3323,11 @@
max-width: var(--container-3xl);
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.xl\:max-w-\(--breakpoint-xl\) {
@media (width >= 80rem) {
max-width: var(--breakpoint-xl);
@@ -3799,6 +3895,51 @@
}
}
}
.\[\&\:\:-webkit-slider-thumb\]\:relative {
&::-webkit-slider-thumb {
position: relative;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-10 {
&::-webkit-slider-thumb {
z-index: 10;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-20 {
&::-webkit-slider-thumb {
z-index: 20;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:h-4 {
&::-webkit-slider-thumb {
height: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:w-4 {
&::-webkit-slider-thumb {
width: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer {
&::-webkit-slider-thumb {
cursor: pointer;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:appearance-none {
&::-webkit-slider-thumb {
appearance: none;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:rounded-full {
&::-webkit-slider-thumb {
border-radius: calc(infinity * 1px);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:bg-brand {
&::-webkit-slider-thumb {
background-color: var(--color-brand);
}
}
.\[\&\:first-of-type_button\]\:rounded-s-lg {
&:first-of-type button {
border-start-start-radius: var(--radius-lg);
@@ -5032,6 +5173,21 @@ form input:disabled, select:disabled, textarea:disabled {
syntax: "*";
inherits: false;
}
@property --tw-scale-x {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-y {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-z {
syntax: "*";
inherits: false;
initial-value: 1;
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -5099,6 +5255,9 @@ form input:disabled, select:disabled, textarea:disabled {
--tw-backdrop-sepia: initial;
--tw-duration: initial;
--tw-ease: initial;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
}
}
}
+380
View File
@@ -0,0 +1,380 @@
/**
* Filter bar — vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
// Read all SelectableFilter widgets first
readSelectableFilters(form);
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
filter[field] = {
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
// Skip if both equal the data range extremes (no real filter)
var yrMinNum = parseInt(yearMin, 10);
var yrMaxNum = parseInt(yearMax, 10);
if (yrMinNum === yrMaxNum) {
// don't add filter
} else {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
}
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
injectSearchInputs();
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
+96
View File
@@ -0,0 +1,96 @@
/**
* Dual-handle range slider — pure JS with draggable handles.
*/
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
var track = slider.querySelector(".range-track-fill");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
var dMin = parseInt(slider.getAttribute("data-min"), 10);
var dMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
function percentToValue(p) {
var raw = dMin + (p / 100) * (dMax - dMin);
return Math.round(raw / step) * step;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
function setTargetVal(el, v) { if (el) el.value = v; }
function update() {
var minV = getTargetVal(minTarget);
var maxV = getTargetVal(maxTarget);
minV = clamp(minV, dMin, dMax);
maxV = clamp(maxV, dMin, dMax);
if (minV > maxV) minV = maxV;
if (maxV < minV) maxV = minV;
setTargetVal(minTarget, minV);
setTargetVal(maxTarget, maxV);
var minP = valueToPercent(minV);
var maxP = valueToPercent(maxV);
minHandle.style.left = minP + "%";
maxHandle.style.left = maxP + "%";
if (track) {
track.style.left = minP + "%";
track.style.width = (maxP - minP) + "%";
}
}
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var v = percentToValue(clamp(pct, 0, 100));
if (isMin) {
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
} else {
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
}
update();
// Trigger input event on the target so any listeners fire
var tgt = isMin ? minTarget : maxTarget;
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// Sync from inputs to slider
function fromInputs() { update(); }
if (minTarget) minTarget.addEventListener("input", fromInputs);
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
update();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
// Expose for manual re-init (filter bar toggle)
window.initRangeSliders = initAll;
})();
+149
View File
@@ -0,0 +1,149 @@
/**
* SelectableFilter widget — Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();