Refine filters
This commit is contained in:
+533
-260
@@ -1,5 +1,7 @@
|
|||||||
"""Stash-style filter bars and the SelectableFilter widget."""
|
"""Stash-style filter bars and the SelectableFilter widget."""
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
@@ -7,6 +9,14 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
from common.components.core import Component
|
from common.components.core import Component
|
||||||
|
|
||||||
|
|
||||||
|
class FilterChoice(NamedTuple):
|
||||||
|
"""Parsed state of a SelectableFilter widget from a filter JSON blob."""
|
||||||
|
|
||||||
|
selected: list[str]
|
||||||
|
excluded: list[str]
|
||||||
|
modifier: str
|
||||||
|
|
||||||
|
|
||||||
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
||||||
|
|
||||||
|
|
||||||
@@ -38,18 +48,52 @@ def _filter_parse(filter_json: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _filter_get_choice(existing: dict, field: str) -> tuple[list[str], list[str], str]:
|
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
||||||
raw = existing.get(field, {})
|
raw = existing.get(field, {})
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return [], [], ""
|
return FilterChoice([], [], "")
|
||||||
val = raw.get("value", [])
|
value = raw.get("value", [])
|
||||||
excl = raw.get("excludes", [])
|
excluded = raw.get("excludes", [])
|
||||||
mod = raw.get("modifier", "")
|
modifier = raw.get("modifier", "")
|
||||||
if isinstance(val, str):
|
if isinstance(value, str):
|
||||||
val = [val]
|
value = [value]
|
||||||
if isinstance(excl, str):
|
if isinstance(excluded, str):
|
||||||
excl = [excl]
|
excluded = [excluded]
|
||||||
return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or ""
|
return FilterChoice(
|
||||||
|
selected=[str(v) for v in (value or [])],
|
||||||
|
excluded=[str(v) for v in (excluded or [])],
|
||||||
|
modifier=modifier or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_range(existing: dict, key: str) -> tuple[str, str]:
|
||||||
|
"""Extract (value, value2) from a filter criterion, defaulting to ("", "")."""
|
||||||
|
field = existing.get(key, {})
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
return "", ""
|
||||||
|
return str(field.get("value", "")), str(field.get("value2", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(existing: dict, key: str) -> bool:
|
||||||
|
"""Extract a boolean value from a filter criterion."""
|
||||||
|
field = existing.get(key, {})
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
return False
|
||||||
|
return bool(field.get("value", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]:
|
||||||
|
"""Return (value, label) pairs for a SelectableFilter from model rows.
|
||||||
|
|
||||||
|
Uses values_list for efficiency (only fetches needed columns),
|
||||||
|
but unpacks each row into readable local variables.
|
||||||
|
"""
|
||||||
|
options: list[tuple[str, str]] = []
|
||||||
|
for object_id, object_name in model_class.objects.order_by(order_by).values_list(
|
||||||
|
"id", order_by
|
||||||
|
):
|
||||||
|
options.append((str(object_id), object_name))
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _filter_mins_to_hrs(val) -> str:
|
def _filter_mins_to_hrs(val) -> str:
|
||||||
@@ -118,50 +162,217 @@ def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_range_inputs(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"):
|
# SVG icons for the mode toggle (shared across all RangeSliders)
|
||||||
"""Twin <input type=range> slider (used by the game filter bar)."""
|
_RANGE_ICON_SVG = (
|
||||||
mv = min_v or str(dmin)
|
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||||
xv = max_v or str(dmax)
|
'<line x1="3" y1="5" x2="13" y2="5" stroke="currentColor" stroke-width="1.5"/>'
|
||||||
|
'<circle cx="3" cy="5" r="3" fill="currentColor"/>'
|
||||||
|
'<circle cx="13" cy="5" r="3" fill="currentColor"/>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
_POINT_ICON_SVG = (
|
||||||
|
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||||
|
'<circle cx="8" cy="5" r="3" fill="currentColor"/>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
_RANGE_SLIDER_INPUT_CLASS = (
|
||||||
|
"w-24 rounded-base border border-default-medium bg-neutral-secondary-medium "
|
||||||
|
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def RangeSlider(
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
input_name_prefix: str,
|
||||||
|
min_value: str = "",
|
||||||
|
max_value: str = "",
|
||||||
|
range_min: int,
|
||||||
|
range_max: int,
|
||||||
|
step: str = "1",
|
||||||
|
min_placeholder: str = "",
|
||||||
|
max_placeholder: str = "",
|
||||||
|
) -> SafeText:
|
||||||
|
"""A labelled range slider with number inputs and range/point mode toggle.
|
||||||
|
|
||||||
|
Renders a label row (label, two number inputs, toggle button) and a slider
|
||||||
|
row (track with one or two custom draggable handles). Defaults to range mode
|
||||||
|
(two handles). If min_value and max_value are both set and equal, starts in
|
||||||
|
point mode (single handle). The toggle switches between modes.
|
||||||
|
"""
|
||||||
|
min_input_id = f"{input_name_prefix}-min"
|
||||||
|
max_input_id = f"{input_name_prefix}-max"
|
||||||
|
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||||
|
initial_mode = "point" if point_mode else "range"
|
||||||
|
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")],
|
attributes=[("class", "range-slider-block mb-4")],
|
||||||
children=[
|
children=[
|
||||||
mark_safe(
|
# ── Label row ──
|
||||||
f'<input type="range" class="range-min absolute w-full pointer-events-none '
|
Component(
|
||||||
f"appearance-none bg-transparent h-2 "
|
tag_name="div",
|
||||||
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
|
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||||
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
|
children=[
|
||||||
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
|
Component(
|
||||||
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
|
tag_name="label",
|
||||||
f'data-target="{min_id}" data-peer="{max_id}" '
|
attributes=[
|
||||||
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">'
|
("class", _FILTER_LABEL_CLASS),
|
||||||
f'<input type="range" class="range-max absolute w-full pointer-events-none '
|
("for", min_input_id),
|
||||||
f"appearance-none bg-transparent h-2 "
|
],
|
||||||
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
|
children=[label],
|
||||||
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
|
),
|
||||||
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
|
Component(
|
||||||
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
|
tag_name="input",
|
||||||
f'data-target="{max_id}" data-peer="{min_id}" '
|
attributes=[
|
||||||
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
|
("type", "number"),
|
||||||
|
("name", min_input_id),
|
||||||
|
("id", min_input_id),
|
||||||
|
("value", min_value),
|
||||||
|
("placeholder", min_placeholder),
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||||
|
+ (" hidden" if point_mode else ""),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"):
|
attributes=[
|
||||||
"""Handle-based slider (used by the session & purchase filter bars)."""
|
(
|
||||||
return Component(
|
"class",
|
||||||
|
"range-dash text-body text-sm"
|
||||||
|
+ (" hidden" if point_mode else ""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children=["–"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="input",
|
||||||
|
attributes=[
|
||||||
|
("type", "number"),
|
||||||
|
("name", max_input_id),
|
||||||
|
("id", max_input_id),
|
||||||
|
("value", max_value),
|
||||||
|
("placeholder", max_placeholder),
|
||||||
|
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[
|
||||||
|
("type", "button"),
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||||
|
"rounded cursor-pointer shrink-0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
"Toggle between range and single value",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"aria-label",
|
||||||
|
"Toggle between range and single value",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-mode-icon-range"
|
||||||
|
+ (" hidden" if point_mode else ""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-mode-icon-point"
|
||||||
|
+ ("" if point_mode else " hidden"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children=[mark_safe(_POINT_ICON_SVG)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# ── Slider row ──
|
||||||
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"),
|
("class", "range-slider relative h-10 select-none mt-1"),
|
||||||
("data-min", str(lo)),
|
("data-mode", initial_mode),
|
||||||
("data-max", str(hi)),
|
("data-min", str(range_min)),
|
||||||
|
("data-max", str(range_max)),
|
||||||
("data-step", str(step)),
|
("data-step", str(step)),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
mark_safe(
|
Component(
|
||||||
'<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'
|
tag_name="div",
|
||||||
+ f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{min_id}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{max_id}" style="left:100%"></div>'
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||||
|
"rounded-full bg-neutral-quaternary",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||||
|
"h-2 bg-brand rounded-full",
|
||||||
|
),
|
||||||
|
("style", "left:0;width:100%"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Min handle (hidden in point mode via JS)
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-handle range-handle-min absolute top-1/2 "
|
||||||
|
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||||
|
"border-2 border-white shadow cursor-pointer "
|
||||||
|
"hover:scale-110 transition-transform",
|
||||||
|
),
|
||||||
|
("data-target", min_input_id),
|
||||||
|
(
|
||||||
|
"style",
|
||||||
|
"left:0"
|
||||||
|
+ (";display:none" if point_mode else ""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Max handle
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"range-handle range-handle-max absolute top-1/2 "
|
||||||
|
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||||
|
"border-2 border-white shadow cursor-pointer "
|
||||||
|
"hover:scale-110 transition-transform",
|
||||||
|
),
|
||||||
|
("data-target", max_input_id),
|
||||||
|
("style", "left:100%"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -371,50 +582,38 @@ def FilterBar(
|
|||||||
if status_options is None:
|
if status_options is None:
|
||||||
status_options = [(s.value, s.label) for s in Game.Status]
|
status_options = [(s.value, s.label) for s in Game.Status]
|
||||||
if platform_options is None:
|
if platform_options is None:
|
||||||
platform_options = list(
|
platform_options = _get_filter_options(Platform)
|
||||||
Platform.objects.order_by("name").values_list("id", "name")
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
status_sel, status_excl, status_mod = _filter_get_choice(existing, "status")
|
status_choice = _filter_get_choice(existing, "status")
|
||||||
plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform")
|
platform_choice = _filter_get_choice(existing, "platform")
|
||||||
plat_opts_str = [(str(k), v) for k, v in platform_options]
|
platform_options_str = [(str(pk), name) for pk, name in platform_options]
|
||||||
|
|
||||||
year_rel = existing.get("year_released", {})
|
year_min, year_max = _parse_range(existing, "year_released")
|
||||||
year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else ""
|
mastered_value = _parse_bool(existing, "mastered")
|
||||||
year_max = str(year_rel.get("value2", "")) if isinstance(year_rel, dict) else ""
|
|
||||||
mastered_val = (
|
|
||||||
existing.get("mastered", {}).get("value", False)
|
|
||||||
if isinstance(existing.get("mastered"), dict)
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
playtime = existing.get("playtime_minutes", {})
|
playtime = existing.get("playtime_minutes", {})
|
||||||
playtime_min = (
|
if isinstance(playtime, dict):
|
||||||
_filter_mins_to_hrs(playtime.get("value", ""))
|
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
||||||
if isinstance(playtime, dict)
|
playtime_max = _filter_mins_to_hrs(playtime.get("value2", ""))
|
||||||
else ""
|
else:
|
||||||
)
|
playtime_min = ""
|
||||||
playtime_max = (
|
playtime_max = ""
|
||||||
_filter_mins_to_hrs(playtime.get("value2", ""))
|
|
||||||
if isinstance(playtime, dict)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
year_agg = Game.objects.aggregate(
|
year_aggregate = Game.objects.aggregate(
|
||||||
yr_min=models.Min("year_released"), yr_max=models.Max("year_released")
|
year_min=models.Min("year_released"), year_max=models.Max("year_released")
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
year_agg = {}
|
year_aggregate = {}
|
||||||
try:
|
try:
|
||||||
pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime"))
|
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pt_agg = {}
|
playtime_aggregate = {}
|
||||||
yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970)
|
year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
|
||||||
yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030)
|
year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
|
||||||
pt_data_max = (
|
playtime_range_max = (
|
||||||
int((pt_agg.get("pt_max") or 0).total_seconds() / 3600)
|
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
||||||
if pt_agg.get("pt_max")
|
if playtime_aggregate.get("playtime_max")
|
||||||
else 200
|
else 200
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,9 +627,9 @@ def FilterBar(
|
|||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"status",
|
"status",
|
||||||
status_options,
|
status_options,
|
||||||
status_sel,
|
status_choice.selected,
|
||||||
status_excl,
|
status_choice.excluded,
|
||||||
status_mod,
|
status_choice.modifier,
|
||||||
nullable=not Game._meta.get_field("status").has_default(),
|
nullable=not Game._meta.get_field("status").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -438,61 +637,155 @@ def FilterBar(
|
|||||||
"Platform",
|
"Platform",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"platform",
|
"platform",
|
||||||
plat_opts_str,
|
platform_options_str,
|
||||||
plat_sel,
|
platform_choice.selected,
|
||||||
plat_excl,
|
platform_choice.excluded,
|
||||||
plat_mod,
|
platform_choice.modifier,
|
||||||
nullable=Game._meta.get_field("platform").null,
|
nullable=Game._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_number("Year Min", "filter-year-min", year_min, "e.g. 2020"),
|
|
||||||
_filter_number("Year Max", "filter-year-max", year_max, "e.g. 2024"),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_filter_range_inputs(
|
RangeSlider(
|
||||||
"year-range",
|
label="Year",
|
||||||
"filter-year-min",
|
input_name_prefix="filter-year",
|
||||||
"filter-year-max",
|
min_value=year_min,
|
||||||
year_min,
|
max_value=year_max,
|
||||||
year_max,
|
range_min=year_range_min,
|
||||||
yr_data_min,
|
range_max=year_range_max,
|
||||||
yr_data_max,
|
min_placeholder="e.g. 2020",
|
||||||
|
max_placeholder="e.g. 2024",
|
||||||
),
|
),
|
||||||
Component(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||||
children=[
|
children=[
|
||||||
_filter_number(
|
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
||||||
"Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1"
|
|
||||||
),
|
|
||||||
_filter_number(
|
|
||||||
"Playtime Max (hrs)",
|
|
||||||
"filter-playtime-max",
|
|
||||||
playtime_max,
|
|
||||||
"e.g. 100",
|
|
||||||
),
|
|
||||||
Component(
|
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "flex items-end pb-1")],
|
|
||||||
children=[
|
|
||||||
_filter_checkbox("filter-mastered", "Mastered", mastered_val)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
RangeSlider(
|
||||||
),
|
label="Playtime",
|
||||||
_filter_range_inputs(
|
input_name_prefix="filter-playtime",
|
||||||
"playtime-range",
|
min_value=playtime_min,
|
||||||
"filter-playtime-min",
|
max_value=playtime_max,
|
||||||
"filter-playtime-max",
|
range_min=0,
|
||||||
playtime_min or "0",
|
range_max=playtime_range_max,
|
||||||
playtime_max or str(pt_data_max),
|
step="1",
|
||||||
0,
|
min_placeholder="e.g. 1",
|
||||||
pt_data_max,
|
max_placeholder="e.g. 100",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable_filter_tag(
|
||||||
|
value: str, label: str, *, excluded: bool = False
|
||||||
|
) -> SafeText:
|
||||||
|
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
|
||||||
|
checkmark = "\u2717" if excluded else "\u2713"
|
||||||
|
css = "sf-tag sf-excluded" if excluded else "sf-tag"
|
||||||
|
return Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[
|
||||||
|
("class", css),
|
||||||
|
("data-value", value),
|
||||||
|
("data-type", "exclude" if excluded else "include"),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "sf-tag-text")],
|
||||||
|
children=[f"{checkmark} {label}"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[
|
||||||
|
("type", "button"),
|
||||||
|
("class", "sf-remove"),
|
||||||
|
("aria-label", "Remove"),
|
||||||
|
],
|
||||||
|
children=["\u00d7"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
|
||||||
|
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
|
||||||
|
return Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[
|
||||||
|
("class", "sf-modifier-tag active"),
|
||||||
|
("data-modifier", modifier),
|
||||||
|
],
|
||||||
|
children=[label],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
|
||||||
|
"""A modifier choice in the SelectableFilter dropdown list."""
|
||||||
|
return Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[
|
||||||
|
("class", "sf-option sf-modifier-option"),
|
||||||
|
("data-modifier", modifier),
|
||||||
|
("data-label", label),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "sf-option-label")],
|
||||||
|
children=[label],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable_filter_option(value: str, label: str) -> SafeText:
|
||||||
|
"""An option row with include (+) and exclude (\u2212) buttons."""
|
||||||
|
return Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[
|
||||||
|
("class", "sf-option"),
|
||||||
|
("data-value", value),
|
||||||
|
("data-label", label),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "sf-option-label")],
|
||||||
|
children=[label],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "sf-option-buttons")],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[
|
||||||
|
("type", "button"),
|
||||||
|
("class", "sf-btn-include"),
|
||||||
|
("data-action", "include"),
|
||||||
|
("title", "Include"),
|
||||||
|
],
|
||||||
|
children=["+"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[
|
||||||
|
("type", "button"),
|
||||||
|
("class", "sf-btn-exclude"),
|
||||||
|
("data-action", "exclude"),
|
||||||
|
("title", "Exclude"),
|
||||||
|
],
|
||||||
|
children=["\u2212"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def SelectableFilter(
|
def SelectableFilter(
|
||||||
field_name: str,
|
field_name: str,
|
||||||
options: list[tuple[str, str]],
|
options: list[tuple[str, str]],
|
||||||
@@ -505,87 +798,70 @@ def SelectableFilter(
|
|||||||
selected = selected or []
|
selected = selected or []
|
||||||
excluded = excluded or []
|
excluded = excluded or []
|
||||||
|
|
||||||
active_mod_html = ""
|
modifier_options = [("NOT_NULL", "(Any)")]
|
||||||
inactive_mod_html = ""
|
|
||||||
mod_opts = [("NOT_NULL", "(Any)")]
|
|
||||||
if nullable:
|
if nullable:
|
||||||
mod_opts.append(("IS_NULL", "(None)"))
|
modifier_options.append(("IS_NULL", "(None)"))
|
||||||
for mod_val, mod_label in mod_opts:
|
|
||||||
if modifier == mod_val:
|
active_modifier_tag = ""
|
||||||
active_mod_html = (
|
inactive_modifier_options: list[SafeText] = []
|
||||||
f'<span class="sf-modifier-tag active" data-modifier="{mod_val}">'
|
for modifier_value, modifier_label in modifier_options:
|
||||||
f"{mod_label}</span> "
|
if modifier == modifier_value:
|
||||||
|
active_modifier_tag = _selectable_filter_modifier_tag(
|
||||||
|
modifier_value, modifier_label
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
inactive_mod_html += (
|
inactive_modifier_options.append(
|
||||||
f'<div class="sf-option sf-modifier-option" data-modifier="{mod_val}" '
|
_selectable_filter_modifier_option(modifier_value, modifier_label)
|
||||||
f'data-label="{mod_label}">'
|
|
||||||
f'<span class="sf-option-label">{mod_label}</span></div>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_html = ""
|
selected_tags: list[SafeText] = []
|
||||||
for val in selected:
|
for value in selected:
|
||||||
label = _find_label(options, val)
|
selected_tags.append(
|
||||||
selected_html += (
|
_selectable_filter_tag(value, _find_label(options, value), excluded=False)
|
||||||
f'<span class="sf-tag" data-value="{escape(val)}" data-type="include">'
|
|
||||||
f'<span class="sf-tag-text text-body">\u2713 {escape(label)}</span>'
|
|
||||||
f'<button type="button" class="sf-remove">\u00d7</button></span> '
|
|
||||||
)
|
)
|
||||||
for val in excluded:
|
for value in excluded:
|
||||||
label = _find_label(options, val)
|
selected_tags.append(
|
||||||
selected_html += (
|
_selectable_filter_tag(value, _find_label(options, value), excluded=True)
|
||||||
f'<span class="sf-tag sf-excluded" data-value="{escape(val)}" data-type="exclude">'
|
|
||||||
f'<span class="sf-tag-text text-body">\u2717 {escape(label)}</span>'
|
|
||||||
f'<button type="button" class="sf-remove">\u00d7</button></span> '
|
|
||||||
)
|
)
|
||||||
|
|
||||||
options_html = ""
|
option_rows: list[SafeText] = []
|
||||||
for val, label in options:
|
for value, label in options:
|
||||||
options_html += (
|
option_rows.append(_selectable_filter_option(value, label))
|
||||||
f'<div class="sf-option" data-value="{escape(val)}" data-label="{escape(label)}">'
|
|
||||||
f'<span class="sf-option-label">{escape(label)}</span>'
|
selected_area_children: list[SafeText] = []
|
||||||
f'<span class="sf-option-buttons">'
|
if active_modifier_tag:
|
||||||
f'<button type="button" class="sf-btn-include" data-action="include" title="Include">+</button>'
|
selected_area_children.append(active_modifier_tag)
|
||||||
f'<button type="button" class="sf-btn-exclude" data-action="exclude" title="Exclude">\u2212</button>'
|
selected_area_children.extend(selected_tags)
|
||||||
f"</span></div>"
|
|
||||||
)
|
options_area_children: list[SafeText] = []
|
||||||
|
options_area_children.extend(inactive_modifier_options)
|
||||||
|
options_area_children.extend(option_rows)
|
||||||
|
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
("class", "sf-container"),
|
||||||
"class",
|
|
||||||
"sf-container border border-default-medium rounded-base bg-neutral-secondary-medium",
|
|
||||||
),
|
|
||||||
("data-selectable-filter", field_name),
|
("data-selectable-filter", field_name),
|
||||||
*([("data-modifier", modifier)] if modifier else []),
|
*([("data-modifier", modifier)] if modifier else []),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[("class", "sf-selected")],
|
||||||
("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
|
children=selected_area_children,
|
||||||
],
|
|
||||||
children=[mark_safe(active_mod_html + selected_html)],
|
|
||||||
),
|
),
|
||||||
Component(
|
Component(
|
||||||
tag_name="input",
|
tag_name="input",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "text"),
|
("type", "text"),
|
||||||
(
|
("class", "sf-search"),
|
||||||
"class",
|
|
||||||
"sf-search block w-full border-0 border-t border-default-medium "
|
|
||||||
"bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden",
|
|
||||||
),
|
|
||||||
("placeholder", "Search\u2026"),
|
("placeholder", "Search\u2026"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Component(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[("class", "sf-options")],
|
||||||
("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"),
|
children=options_area_children,
|
||||||
],
|
|
||||||
children=[mark_safe(inactive_mod_html + options_html)],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -604,37 +880,29 @@ def SessionFilterBar(
|
|||||||
"""Collapsible filter bar for the Session list."""
|
"""Collapsible filter bar for the Session list."""
|
||||||
from games.models import Device, Game, Session
|
from games.models import Device, Game, Session
|
||||||
|
|
||||||
game_opts = [
|
game_options = _get_filter_options(Game)
|
||||||
(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
|
device_options = _get_filter_options(Device)
|
||||||
]
|
|
||||||
dev_opts = [
|
|
||||||
(str(k), v)
|
|
||||||
for k, v in Device.objects.order_by("name").values_list("id", "name")
|
|
||||||
]
|
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
gs, ge, gm = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
ds, de, dm = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
|
|
||||||
dur = existing.get("duration_minutes", {})
|
duration_min, duration_max = _parse_range(existing, "duration_minutes")
|
||||||
dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else ""
|
duration_min = _filter_mins_to_hrs(duration_min)
|
||||||
dmax = _filter_mins_to_hrs(dur.get("value2", "")) if isinstance(dur, dict) else ""
|
duration_max = _filter_mins_to_hrs(duration_max)
|
||||||
em = (
|
emulated_value = _parse_bool(existing, "emulated")
|
||||||
existing.get("emulated", {}).get("value", False)
|
is_active_value = _parse_bool(existing, "is_active")
|
||||||
if isinstance(existing.get("emulated"), dict)
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
ac = (
|
|
||||||
existing.get("is_active", {}).get("value", False)
|
|
||||||
if isinstance(existing.get("is_active"), dict)
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
a = Session.objects.aggregate(m=models.Max("duration_total"))
|
duration_aggregate = Session.objects.aggregate(
|
||||||
ddm = max(
|
duration_max=models.Max("duration_total")
|
||||||
int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1
|
)
|
||||||
|
duration_range_max = max(
|
||||||
|
int((duration_aggregate.get("duration_max") or 0).total_seconds() / 3600)
|
||||||
|
if duration_aggregate.get("duration_max")
|
||||||
|
else 200,
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
ddm = 200
|
duration_range_max = 200
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Component(
|
||||||
@@ -645,10 +913,10 @@ def SessionFilterBar(
|
|||||||
"Game",
|
"Game",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"game",
|
"game",
|
||||||
game_opts,
|
game_options,
|
||||||
gs,
|
game_choice.selected,
|
||||||
ge,
|
game_choice.excluded,
|
||||||
gm,
|
game_choice.modifier,
|
||||||
nullable=not Game._meta.get_field("name").has_default(),
|
nullable=not Game._meta.get_field("name").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -656,30 +924,31 @@ def SessionFilterBar(
|
|||||||
"Device",
|
"Device",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"device",
|
"device",
|
||||||
dev_opts,
|
device_options,
|
||||||
ds,
|
device_choice.selected,
|
||||||
de,
|
device_choice.excluded,
|
||||||
dm,
|
device_choice.modifier,
|
||||||
nullable=Session._meta.get_field("device").null,
|
nullable=Session._meta.get_field("device").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_number(
|
|
||||||
"Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5"
|
|
||||||
),
|
|
||||||
_filter_number(
|
|
||||||
"Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10"
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_filter_range_handles(
|
RangeSlider(
|
||||||
"dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm
|
label="Duration",
|
||||||
|
input_name_prefix="filter-playtime",
|
||||||
|
min_value=duration_min,
|
||||||
|
max_value=duration_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=duration_range_max,
|
||||||
|
min_placeholder="e.g. 0.5",
|
||||||
|
max_placeholder="e.g. 10",
|
||||||
),
|
),
|
||||||
Component(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", "flex gap-4 mb-4")],
|
attributes=[("class", "flex gap-4 mb-4")],
|
||||||
children=[
|
children=[
|
||||||
_filter_checkbox("filter-emulated", "Emulated", em),
|
_filter_checkbox("filter-emulated", "Emulated", emulated_value),
|
||||||
_filter_checkbox("filter-active", "Active", ac),
|
_filter_checkbox("filter-active", "Active", is_active_value),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -692,33 +961,25 @@ def PurchaseFilterBar(
|
|||||||
"""Collapsible filter bar for the Purchase list."""
|
"""Collapsible filter bar for the Purchase list."""
|
||||||
from games.models import Game, Platform, Purchase
|
from games.models import Game, Platform, Purchase
|
||||||
|
|
||||||
game_opts = [
|
game_options = _get_filter_options(Game)
|
||||||
(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
|
platform_options = _get_filter_options(Platform)
|
||||||
]
|
type_options = [(value, label) for value, label in Purchase.TYPES]
|
||||||
plat_opts = [
|
ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES]
|
||||||
(str(k), v)
|
|
||||||
for k, v in Platform.objects.order_by("name").values_list("id", "name")
|
|
||||||
]
|
|
||||||
type_opts = [(t[0], t[1]) for t in Purchase.TYPES]
|
|
||||||
own_opts = [(t[0], t[1]) for t in Purchase.OWNERSHIP_TYPES]
|
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
gs, ge, gm = _filter_get_choice(existing, "games")
|
game_choice = _filter_get_choice(existing, "games")
|
||||||
ps, pe, pm = _filter_get_choice(existing, "platform")
|
platform_choice = _filter_get_choice(existing, "platform")
|
||||||
ts, te, tm = _filter_get_choice(existing, "type")
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
os_, oe, om = _filter_get_choice(existing, "ownership_type")
|
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
||||||
price = existing.get("price", {})
|
price_min, price_max = _parse_range(existing, "price")
|
||||||
pmin = str(price.get("value", "")) if isinstance(price, dict) else ""
|
is_refunded_value = _parse_bool(existing, "is_refunded")
|
||||||
pmax = str(price.get("value2", "")) if isinstance(price, dict) else ""
|
|
||||||
rf = (
|
|
||||||
existing.get("is_refunded", {}).get("value", False)
|
|
||||||
if isinstance(existing.get("is_refunded"), dict)
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price"))
|
price_aggregate = Purchase.objects.aggregate(
|
||||||
plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1)
|
price_min=models.Min("price"), price_max=models.Max("price")
|
||||||
|
)
|
||||||
|
price_range_min = int(price_aggregate.get("price_min") or 0)
|
||||||
|
price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
|
||||||
except Exception:
|
except Exception:
|
||||||
plo, phi = 0, 100
|
price_range_min, price_range_max = 0, 100
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Component(
|
||||||
@@ -727,16 +988,23 @@ def PurchaseFilterBar(
|
|||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Game",
|
"Game",
|
||||||
SelectableFilter("games", game_opts, gs, ge, gm, nullable=False),
|
SelectableFilter(
|
||||||
|
"games",
|
||||||
|
game_options,
|
||||||
|
game_choice.selected,
|
||||||
|
game_choice.excluded,
|
||||||
|
game_choice.modifier,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Platform",
|
"Platform",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"platform",
|
"platform",
|
||||||
plat_opts,
|
platform_options,
|
||||||
ps,
|
platform_choice.selected,
|
||||||
pe,
|
platform_choice.excluded,
|
||||||
pm,
|
platform_choice.modifier,
|
||||||
nullable=Purchase._meta.get_field("platform").null,
|
nullable=Purchase._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -744,10 +1012,10 @@ def PurchaseFilterBar(
|
|||||||
"Type",
|
"Type",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"type",
|
"type",
|
||||||
type_opts,
|
type_options,
|
||||||
ts,
|
type_choice.selected,
|
||||||
te,
|
type_choice.excluded,
|
||||||
tm,
|
type_choice.modifier,
|
||||||
nullable=not Purchase._meta.get_field("type").has_default(),
|
nullable=not Purchase._meta.get_field("type").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -755,10 +1023,10 @@ def PurchaseFilterBar(
|
|||||||
"Ownership",
|
"Ownership",
|
||||||
SelectableFilter(
|
SelectableFilter(
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
own_opts,
|
ownership_options,
|
||||||
os_,
|
ownership_choice.selected,
|
||||||
oe,
|
ownership_choice.excluded,
|
||||||
om,
|
ownership_choice.modifier,
|
||||||
nullable=not Purchase._meta.get_field(
|
nullable=not Purchase._meta.get_field(
|
||||||
"ownership_type"
|
"ownership_type"
|
||||||
).has_default(),
|
).has_default(),
|
||||||
@@ -768,15 +1036,20 @@ def PurchaseFilterBar(
|
|||||||
),
|
),
|
||||||
Component(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||||
children=[
|
children=[
|
||||||
_filter_number("Price Min", "filter-price-min", pmin, "0.00"),
|
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
||||||
_filter_number("Price Max", "filter-price-max", pmax, "100.00"),
|
|
||||||
_filter_checkbox("filter-refunded", "Refunded", rf),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_filter_range_handles(
|
RangeSlider(
|
||||||
"price-range", "filter-price-min", "filter-price-max", plo, phi
|
label="Price",
|
||||||
|
input_name_prefix="filter-price",
|
||||||
|
min_value=price_min,
|
||||||
|
max_value=price_max,
|
||||||
|
range_min=price_range_min,
|
||||||
|
range_max=price_range_max,
|
||||||
|
min_placeholder="0.00",
|
||||||
|
max_placeholder="100.00",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|||||||
@@ -231,3 +231,49 @@ textarea:disabled {
|
|||||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SelectableFilter widget styling */
|
||||||
|
.sf-container {
|
||||||
|
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
|
||||||
|
}
|
||||||
|
.sf-selected {
|
||||||
|
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
|
||||||
|
}
|
||||||
|
.sf-tag {
|
||||||
|
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
|
||||||
|
}
|
||||||
|
.sf-tag.sf-excluded {
|
||||||
|
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
|
||||||
|
}
|
||||||
|
.sf-remove {
|
||||||
|
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
|
||||||
|
}
|
||||||
|
.sf-modifier-tag {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
|
||||||
|
}
|
||||||
|
.sf-search {
|
||||||
|
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
|
||||||
|
&:focus {
|
||||||
|
@apply ring-0 outline-hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sf-options {
|
||||||
|
@apply max-h-40 overflow-y-auto p-1 text-body;
|
||||||
|
}
|
||||||
|
.sf-option {
|
||||||
|
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
|
||||||
|
}
|
||||||
|
.sf-option-label {
|
||||||
|
@apply truncate;
|
||||||
|
}
|
||||||
|
.sf-option-buttons {
|
||||||
|
@apply flex gap-1 ml-2 shrink-0;
|
||||||
|
}
|
||||||
|
.sf-btn-include,
|
||||||
|
.sf-btn-exclude {
|
||||||
|
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
|
||||||
|
}
|
||||||
|
.sf-modifier-option {
|
||||||
|
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+171
-63
@@ -1470,15 +1470,9 @@
|
|||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.max-h-40 {
|
|
||||||
max-height: calc(var(--spacing) * 40);
|
|
||||||
}
|
|
||||||
.max-h-full {
|
.max-h-full {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
.min-h-\[28px\] {
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -1655,6 +1649,9 @@
|
|||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.-translate-x-full {
|
.-translate-x-full {
|
||||||
--tw-translate-x: -100%;
|
--tw-translate-x: -100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1713,9 +1710,6 @@
|
|||||||
.list-disc {
|
.list-disc {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
.appearance-none {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2125,6 +2119,9 @@
|
|||||||
.bg-neutral-primary-soft {
|
.bg-neutral-primary-soft {
|
||||||
background-color: var(--color-neutral-primary-soft);
|
background-color: var(--color-neutral-primary-soft);
|
||||||
}
|
}
|
||||||
|
.bg-neutral-quaternary {
|
||||||
|
background-color: var(--color-neutral-quaternary);
|
||||||
|
}
|
||||||
.bg-neutral-secondary-medium {
|
.bg-neutral-secondary-medium {
|
||||||
background-color: var(--color-neutral-secondary-medium);
|
background-color: var(--color-neutral-secondary-medium);
|
||||||
}
|
}
|
||||||
@@ -2331,9 +2328,6 @@
|
|||||||
color: heading !important;
|
color: heading !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pb-1 {
|
|
||||||
padding-bottom: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.pb-16 {
|
.pb-16 {
|
||||||
padding-bottom: calc(var(--spacing) * 16);
|
padding-bottom: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
@@ -3074,12 +3068,6 @@
|
|||||||
color: var(--color-blue-700);
|
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\:ring-2 {
|
||||||
&:focus {
|
&:focus {
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||||
@@ -3895,51 +3883,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.\[\&\:\:-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\]\:rounded-s-lg {
|
||||||
&:first-of-type button {
|
&:first-of-type button {
|
||||||
border-start-start-radius: var(--radius-lg);
|
border-start-start-radius: var(--radius-lg);
|
||||||
@@ -4358,6 +4301,171 @@ form input:disabled, select:disabled, textarea:disabled {
|
|||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sf-container {
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: var(--color-default-medium);
|
||||||
|
background-color: var(--color-neutral-secondary-medium);
|
||||||
|
}
|
||||||
|
.sf-selected {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: calc(var(--spacing) * 1);
|
||||||
|
padding: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.sf-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--spacing) * 1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
|
}
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
.sf-tag.sf-excluded {
|
||||||
|
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
|
||||||
|
}
|
||||||
|
color: var(--color-red-600);
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
text-decoration-color: var(--color-red-400);
|
||||||
|
}
|
||||||
|
.sf-remove {
|
||||||
|
margin-left: calc(var(--spacing) * 1);
|
||||||
|
cursor: pointer;
|
||||||
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-body);
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sf-modifier-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
||||||
|
}
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
color: var(--color-amber-600);
|
||||||
|
}
|
||||||
|
.sf-search {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 0px;
|
||||||
|
border-top-style: var(--tw-border-style);
|
||||||
|
border-top-width: 1px;
|
||||||
|
border-color: var(--color-default-medium);
|
||||||
|
background-color: transparent;
|
||||||
|
padding: calc(var(--spacing) * 2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
color: var(--color-heading);
|
||||||
|
&: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);
|
||||||
|
--tw-outline-style: none;
|
||||||
|
outline-style: none;
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sf-options {
|
||||||
|
max-height: calc(var(--spacing) * 40);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: calc(var(--spacing) * 1);
|
||||||
|
color: var(--color-body);
|
||||||
|
}
|
||||||
|
.sf-option {
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
padding-block: calc(var(--spacing) * 1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-neutral-secondary-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sf-option-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sf-option-buttons {
|
||||||
|
margin-left: calc(var(--spacing) * 2);
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
|
.sf-btn-include, .sf-btn-exclude {
|
||||||
|
display: flex;
|
||||||
|
height: calc(var(--spacing) * 5);
|
||||||
|
width: calc(var(--spacing) * 5);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: var(--color-default-medium);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||||
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sf-modifier-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
padding-block: calc(var(--spacing) * 1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
color: var(--color-body);
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-neutral-secondary-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
|
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
* Returns a plain object ready for JSON.stringify.
|
* Returns a plain object ready for JSON.stringify.
|
||||||
*/
|
*/
|
||||||
function buildFilterJSON(form) {
|
function buildFilterJSON(form) {
|
||||||
// Read all SelectableFilter widgets first
|
|
||||||
readSelectableFilters(form);
|
|
||||||
|
|
||||||
var filter = {};
|
var filter = {};
|
||||||
var yearMin = numberValue(form, "filter-year-min");
|
var yearMin = numberValue(form, "filter-year-min");
|
||||||
var yearMax = numberValue(form, "filter-year-max");
|
var yearMax = numberValue(form, "filter-year-max");
|
||||||
@@ -132,14 +129,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (yearMin !== "" && yearMax !== "") {
|
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");
|
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
|
||||||
}
|
|
||||||
} else if (yearMin !== "") {
|
} else if (yearMin !== "") {
|
||||||
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
||||||
} else if (yearMax !== "") {
|
} else if (yearMax !== "") {
|
||||||
@@ -371,8 +361,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
injectSearchInputs();
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
injectSearchInputs();
|
injectSearchInputs();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
|||||||
+142
-42
@@ -1,5 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Dual-handle range slider — pure JS with draggable handles.
|
* Range slider — custom draggable handles (no native <input type=range>).
|
||||||
|
*
|
||||||
|
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
||||||
|
* range (default) — two handles, min ≤ max constraint
|
||||||
|
* point — single handle, sets both number inputs to the same value
|
||||||
|
*
|
||||||
|
* Handles track-fill positioning and sync between handles and the connected
|
||||||
|
* number inputs (linked via data-target attributes).
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -10,63 +17,109 @@
|
|||||||
if (slider._rsInit) return;
|
if (slider._rsInit) return;
|
||||||
slider._rsInit = true;
|
slider._rsInit = true;
|
||||||
|
|
||||||
|
var mode = slider.getAttribute("data-mode") || "range";
|
||||||
|
var trackFill = slider.querySelector(".range-track-fill");
|
||||||
var minHandle = slider.querySelector(".range-handle-min");
|
var minHandle = slider.querySelector(".range-handle-min");
|
||||||
var maxHandle = slider.querySelector(".range-handle-max");
|
var maxHandle = slider.querySelector(".range-handle-max");
|
||||||
var track = slider.querySelector(".range-track-fill");
|
|
||||||
if (!minHandle || !maxHandle) return;
|
if (!minHandle || !maxHandle) return;
|
||||||
|
|
||||||
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
|
var minTarget = document.getElementById(
|
||||||
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
|
minHandle.getAttribute("data-target")
|
||||||
var dMin = parseInt(slider.getAttribute("data-min"), 10);
|
);
|
||||||
var dMax = parseInt(slider.getAttribute("data-max"), 10);
|
var maxTarget = document.getElementById(
|
||||||
|
maxHandle.getAttribute("data-target")
|
||||||
|
);
|
||||||
|
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||||
|
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||||
|
|
||||||
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
|
// ── Helpers ──
|
||||||
function percentToValue(p) {
|
|
||||||
var raw = dMin + (p / 100) * (dMax - dMin);
|
function valueToPercent(value) {
|
||||||
|
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||||
|
}
|
||||||
|
function percentToValue(percent) {
|
||||||
|
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||||
return Math.round(raw / step) * step;
|
return Math.round(raw / step) * step;
|
||||||
}
|
}
|
||||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
function clamp(value, lo, hi) {
|
||||||
|
return Math.max(lo, Math.min(hi, value));
|
||||||
|
}
|
||||||
|
|
||||||
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
|
function getTargetValue(target) {
|
||||||
function setTargetVal(el, v) { if (el) el.value = v; }
|
return parseInt(target ? target.value : 0, 10) || dataMin;
|
||||||
|
}
|
||||||
|
function setTargetValue(target, value) {
|
||||||
|
if (target) target.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
function update() {
|
// ── Track fill positioning ──
|
||||||
var minV = getTargetVal(minTarget);
|
|
||||||
var maxV = getTargetVal(maxTarget);
|
function updateTrackFill() {
|
||||||
minV = clamp(minV, dMin, dMax);
|
if (!trackFill) return;
|
||||||
maxV = clamp(maxV, dMin, dMax);
|
var minValue = getTargetValue(minTarget);
|
||||||
if (minV > maxV) minV = maxV;
|
var maxValue = getTargetValue(maxTarget);
|
||||||
if (maxV < minV) maxV = minV;
|
if (mode === "point") {
|
||||||
setTargetVal(minTarget, minV);
|
trackFill.style.left = "0%";
|
||||||
setTargetVal(maxTarget, maxV);
|
trackFill.style.width = valueToPercent(maxValue) + "%";
|
||||||
var minP = valueToPercent(minV);
|
} else {
|
||||||
var maxP = valueToPercent(maxV);
|
var leftPct = valueToPercent(minValue);
|
||||||
minHandle.style.left = minP + "%";
|
var widthPct = valueToPercent(maxValue) - leftPct;
|
||||||
maxHandle.style.left = maxP + "%";
|
trackFill.style.left = leftPct + "%";
|
||||||
if (track) {
|
trackFill.style.width = widthPct + "%";
|
||||||
track.style.left = minP + "%";
|
|
||||||
track.style.width = (maxP - minP) + "%";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateHandles() {
|
||||||
|
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
||||||
|
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
||||||
|
updateTrackFill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dragging ──
|
||||||
|
|
||||||
function makeDraggable(handle, isMin) {
|
function makeDraggable(handle, isMin) {
|
||||||
handle.addEventListener("mousedown", function (e) {
|
handle.addEventListener("mousedown", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var rect = slider.getBoundingClientRect();
|
var rect = slider.getBoundingClientRect();
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||||
var v = percentToValue(clamp(pct, 0, 100));
|
var value = percentToValue(clamp(pct, 0, 100));
|
||||||
if (isMin) {
|
|
||||||
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
|
if (mode === "point") {
|
||||||
|
setTargetValue(minTarget, value);
|
||||||
|
setTargetValue(maxTarget, value);
|
||||||
|
if (minTarget)
|
||||||
|
minTarget.dispatchEvent(
|
||||||
|
new Event("input", { bubbles: true })
|
||||||
|
);
|
||||||
|
if (maxTarget)
|
||||||
|
maxTarget.dispatchEvent(
|
||||||
|
new Event("input", { bubbles: true })
|
||||||
|
);
|
||||||
|
} else if (isMin) {
|
||||||
|
setTargetValue(
|
||||||
|
minTarget,
|
||||||
|
clamp(value, dataMin, getTargetValue(maxTarget))
|
||||||
|
);
|
||||||
|
if (minTarget)
|
||||||
|
minTarget.dispatchEvent(
|
||||||
|
new Event("input", { bubbles: true })
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
|
setTargetValue(
|
||||||
|
maxTarget,
|
||||||
|
clamp(value, getTargetValue(minTarget), dataMax)
|
||||||
|
);
|
||||||
|
if (maxTarget)
|
||||||
|
maxTarget.dispatchEvent(
|
||||||
|
new Event("input", { bubbles: true })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
update();
|
updateHandles();
|
||||||
// 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() {
|
function onUp() {
|
||||||
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mousemove", onMove);
|
||||||
document.removeEventListener("mouseup", onUp);
|
document.removeEventListener("mouseup", onUp);
|
||||||
@@ -80,17 +133,64 @@
|
|||||||
makeDraggable(minHandle, true);
|
makeDraggable(minHandle, true);
|
||||||
makeDraggable(maxHandle, false);
|
makeDraggable(maxHandle, false);
|
||||||
|
|
||||||
// Sync from inputs to slider
|
// ── Sync from number inputs back to handles ──
|
||||||
function fromInputs() { update(); }
|
|
||||||
if (minTarget) minTarget.addEventListener("input", fromInputs);
|
|
||||||
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
|
|
||||||
|
|
||||||
update();
|
function syncFromInputs() {
|
||||||
|
if (mode === "point") {
|
||||||
|
var value =
|
||||||
|
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
||||||
|
setTargetValue(minTarget, value);
|
||||||
|
setTargetValue(maxTarget, value);
|
||||||
|
}
|
||||||
|
updateHandles();
|
||||||
|
}
|
||||||
|
if (minTarget)
|
||||||
|
minTarget.addEventListener("input", syncFromInputs);
|
||||||
|
if (maxTarget)
|
||||||
|
maxTarget.addEventListener("input", syncFromInputs);
|
||||||
|
|
||||||
|
// ── Mode toggle ──
|
||||||
|
|
||||||
|
var block = slider.closest(".range-slider-block");
|
||||||
|
var toggleButton =
|
||||||
|
block && block.querySelector(".range-mode-toggle");
|
||||||
|
if (toggleButton) {
|
||||||
|
toggleButton.addEventListener("click", function () {
|
||||||
|
var newMode = mode === "range" ? "point" : "range";
|
||||||
|
slider.setAttribute("data-mode", newMode);
|
||||||
|
|
||||||
|
// Swap toggle icons
|
||||||
|
var iconRange = toggleButton.querySelector(
|
||||||
|
".range-mode-icon-range"
|
||||||
|
);
|
||||||
|
var iconPoint = toggleButton.querySelector(
|
||||||
|
".range-mode-icon-point"
|
||||||
|
);
|
||||||
|
if (iconRange) iconRange.classList.toggle("hidden");
|
||||||
|
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||||
|
|
||||||
|
var dashSpan = block && block.querySelector(".range-dash");
|
||||||
|
if (newMode === "point") {
|
||||||
|
minHandle.style.display = "none";
|
||||||
|
setTargetValue(minTarget, getTargetValue(maxTarget));
|
||||||
|
if (minTarget) minTarget.classList.add("hidden");
|
||||||
|
if (dashSpan) dashSpan.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
minHandle.style.display = "";
|
||||||
|
if (minTarget) minTarget.classList.remove("hidden");
|
||||||
|
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
mode = newMode;
|
||||||
|
updateHandles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial position ──
|
||||||
|
updateHandles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", initAll);
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
document.addEventListener("htmx:afterSwap", initAll);
|
document.addEventListener("htmx:afterSwap", initAll);
|
||||||
// Expose for manual re-init (filter bar toggle)
|
|
||||||
window.initRangeSliders = initAll;
|
window.initRangeSliders = initAll;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Characterization tests locking the rendered output of the three filter bars.
|
"""Characterization tests locking the rendered output of the three filter bars.
|
||||||
|
|
||||||
The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the
|
The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the
|
||||||
target of an upcoming dedup + module split. These tests pin the structural
|
target of a dedup + module split + RangeSlider component extraction. These tests
|
||||||
contract — form/input ids, the hidden ``filter`` field, preset wiring, the
|
pin the structural contract — form/input ids, the hidden ``filter`` field,
|
||||||
filter_json round-trip, and no double-escaping — so that refactor stays
|
preset wiring, the filter_json round-trip, no double-escaping, and the
|
||||||
behaviour-preserving. The renderers were previously untested.
|
Flowbite-styled native range slider unification — so that refactor stays
|
||||||
|
behaviour-preserving.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -41,6 +42,24 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertIn(save_url, html) # preset save URL wired in
|
self.assertIn(save_url, html) # preset save URL wired in
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
|
def _assert_range_slider(self, html):
|
||||||
|
"""Every filter bar must use the RangeSlider component with custom
|
||||||
|
draggable <div> handles, a track fill, and mode-toggle button."""
|
||||||
|
self.assertIn("range-slider-block", html)
|
||||||
|
self.assertIn('data-mode="range"', html)
|
||||||
|
self.assertIn("range-mode-toggle", html)
|
||||||
|
self.assertIn("range-mode-icon-range", html)
|
||||||
|
self.assertIn("range-mode-icon-point", html)
|
||||||
|
self.assertIn("range-track-fill", html)
|
||||||
|
self.assertIn("range-handle-min", html)
|
||||||
|
self.assertIn("range-handle-max", html)
|
||||||
|
# No native range inputs
|
||||||
|
self.assertNotIn(
|
||||||
|
'<input type="range"',
|
||||||
|
html,
|
||||||
|
"native <input type=range> found — should use custom div handles",
|
||||||
|
)
|
||||||
|
|
||||||
def test_game_filter_bar(self):
|
def test_game_filter_bar(self):
|
||||||
html = str(
|
html = str(
|
||||||
FilterBar(
|
FilterBar(
|
||||||
@@ -50,6 +69,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._assert_shell(html, "/presets/games/list", "/presets/games/save")
|
self._assert_shell(html, "/presets/games/list", "/presets/games/save")
|
||||||
|
self._assert_range_slider(html)
|
||||||
|
|
||||||
def test_session_filter_bar(self):
|
def test_session_filter_bar(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -60,6 +80,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save")
|
self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save")
|
||||||
|
self._assert_range_slider(html)
|
||||||
|
|
||||||
def test_purchase_filter_bar(self):
|
def test_purchase_filter_bar(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -70,6 +91,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||||
|
self._assert_range_slider(html)
|
||||||
|
|
||||||
def test_game_filter_bar_roundtrips_selected_status(self):
|
def test_game_filter_bar_roundtrips_selected_status(self):
|
||||||
"""A status in filter_json renders as a selected tag in the widget."""
|
"""A status in filter_json renders as a selected tag in the widget."""
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Unit tests for filter JSON parsing helpers."""
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from common.components.filters import _parse_bool, _parse_range
|
||||||
|
|
||||||
|
|
||||||
|
class ParseRangeTest(SimpleTestCase):
|
||||||
|
def test_empty_dict(self):
|
||||||
|
self.assertEqual(_parse_range({}, "field"), ("", ""))
|
||||||
|
|
||||||
|
def test_missing_key(self):
|
||||||
|
self.assertEqual(_parse_range({"other": 1}, "field"), ("", ""))
|
||||||
|
|
||||||
|
def test_null_value(self):
|
||||||
|
self.assertEqual(_parse_range({"field": None}, "field"), ("", ""))
|
||||||
|
|
||||||
|
def test_non_dict_value(self):
|
||||||
|
"""A non-dict field value is coerced to ("", "")."""
|
||||||
|
self.assertEqual(_parse_range({"field": "not_a_dict"}, "field"), ("", ""))
|
||||||
|
|
||||||
|
def test_value_only(self):
|
||||||
|
self.assertEqual(_parse_range({"field": {"value": "10"}}, "field"), ("10", ""))
|
||||||
|
|
||||||
|
def test_value_and_value2(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_parse_range({"field": {"value": "10", "value2": "20"}}, "field"),
|
||||||
|
("10", "20"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_strings(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_parse_range({"field": {"value": "", "value2": ""}}, "field"), ("", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_integer_values_become_strings(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_parse_range({"field": {"value": 5, "value2": 15}}, "field"),
|
||||||
|
("5", "15"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseBoolTest(SimpleTestCase):
|
||||||
|
def test_empty_dict(self):
|
||||||
|
self.assertFalse(_parse_bool({}, "field"))
|
||||||
|
|
||||||
|
def test_missing_key(self):
|
||||||
|
self.assertFalse(_parse_bool({"other": 1}, "field"))
|
||||||
|
|
||||||
|
def test_null_value(self):
|
||||||
|
self.assertFalse(_parse_bool({"field": None}, "field"))
|
||||||
|
|
||||||
|
def test_non_dict_value(self):
|
||||||
|
"""A non-dict field value is coerced to False."""
|
||||||
|
self.assertFalse(_parse_bool({"field": "not_a_dict"}, "field"))
|
||||||
|
|
||||||
|
def test_false_value(self):
|
||||||
|
self.assertFalse(_parse_bool({"field": {"value": False}}, "field"))
|
||||||
|
|
||||||
|
def test_true_value(self):
|
||||||
|
self.assertTrue(_parse_bool({"field": {"value": True}}, "field"))
|
||||||
|
|
||||||
|
def test_truthy_string(self):
|
||||||
|
"""Non-empty strings are truthy — bool("yes") is True."""
|
||||||
|
self.assertTrue(_parse_bool({"field": {"value": "yes"}}, "field"))
|
||||||
|
|
||||||
|
def test_missing_value_in_field(self):
|
||||||
|
self.assertFalse(_parse_bool({"field": {}}, "field"))
|
||||||
Reference in New Issue
Block a user