Refine filters

This commit is contained in:
2026-06-06 19:36:44 +02:00
parent ed8589a972
commit 3ce3356064
8 changed files with 993 additions and 2338 deletions
-1950
View File
File diff suppressed because it is too large Load Diff
+534 -261
View File
@@ -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"/>'
return Component( '<circle cx="3" cy="5" r="3" fill="currentColor"/>'
tag_name="div", '<circle cx="13" cy="5" r="3" fill="currentColor"/>'
attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")], "</svg>"
children=[ )
mark_safe(
f'<input type="range" class="range-min absolute w-full pointer-events-none ' _POINT_ICON_SVG = (
f"appearance-none bg-transparent h-2 " '<svg width="16" height="10" viewBox="0 0 16 10">'
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 " '<circle cx="8" cy="5" r="3" fill="currentColor"/>'
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full " "</svg>"
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer " )
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
f'data-target="{min_id}" data-peer="{max_id}" ' _RANGE_SLIDER_INPUT_CLASS = (
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">' "w-24 rounded-base border border-default-medium bg-neutral-secondary-medium "
f'<input type="range" class="range-max absolute w-full pointer-events-none ' "text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
f"appearance-none bg-transparent h-2 "
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
f'data-target="{max_id}" data-peer="{min_id}" '
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
),
],
) )
def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"): def RangeSlider(
"""Handle-based slider (used by the session & purchase filter bars).""" *,
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", "range-slider-block mb-4")],
children=[
# ── Label row ──
Component(
tag_name="div",
attributes=[("class", "flex items-center gap-2 mb-1")],
children=[
Component(
tag_name="label",
attributes=[ attributes=[
("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), ("class", _FILTER_LABEL_CLASS),
("data-min", str(lo)), ("for", min_input_id),
("data-max", str(hi)), ],
children=[label],
),
Component(
tag_name="input",
attributes=[
("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",
attributes=[
(
"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",
attributes=[
("class", "range-slider relative h-10 select-none mt-1"),
("data-mode", initial_mode),
("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)
+46
View File
@@ -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
View File
@@ -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;
-12
View File
@@ -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
View File
@@ -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;
})(); })();
+26 -4
View File
@@ -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."""
+68
View File
@@ -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"))