"""Stash-style filter bars, built from FilterSelect widgets.""" from typing import NamedTuple from django.db import models from django.utils.safestring import SafeText, mark_safe from common.components.core import Component from common.components.primitives import Label, Span from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption class FilterChoice(NamedTuple): """Parsed include/exclude/modifier state of a filter field from filter JSON. ``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For model-backed fields the label is embedded in the filter JSON (Stash-style); for enum fields the label is resolved from the fixed option list. """ selected: list[LabeledOption] excluded: list[LabeledOption] modifier: str class RangeValues(NamedTuple): """A (min, max) string pair parsed from a range filter criterion.""" min: str max: str _FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" _FILTER_CHECKBOX_CLASS = ( "rounded border-default-medium bg-neutral-secondary-medium " "text-brand focus:ring-brand" ) _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" def _filter_parse(filter_json: str) -> dict: if not filter_json: return {} try: import json loaded = json.loads(filter_json) return loaded if isinstance(loaded, dict) else {} except (ValueError, TypeError): return {} def _extract_labeled(items: list[dict]) -> list[LabeledOption]: """Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs.""" return [(str(item["id"]), str(item["label"])) for item in items] def _filter_get_choice(existing: dict, field: str) -> FilterChoice: raw = existing.get(field, {}) if not isinstance(raw, dict): return FilterChoice([], [], "") return FilterChoice( selected=_extract_labeled(raw.get("value") or []), excluded=_extract_labeled(raw.get("excludes") or []), modifier=raw.get("modifier") or "", ) def _parse_range(existing: dict, key: str) -> RangeValues: """Extract (min, max) from a range filter criterion, defaulting to ("", "").""" field = existing.get(key, {}) if not isinstance(field, dict): return RangeValues("", "") return RangeValues(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)) # ── FilterSelect adapters ──────────────────────────────────────────────────── # Each list filter is a FilterSelect. Enum fields pre-render their small, fixed # option set; model-backed fields fetch from a search endpoint on demand, with # labels embedded in the filter JSON so pills render without a DB round-trip. # Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are # mutually exclusive with value pills (selecting one clears the value set). # Must match JS PRESENCE_MODIFIERS in search_select.js. _PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"}) # M2M-only modifiers surfaced as additional pseudo-options in the dropdown. # "any" (INCLUDES) is the implicit default when neither a presence nor an # M2M modifier is set — no dedicated row needed. "none" (EXCLUDES) is # redundant with individual exclude (✗) pills. Only INCLUDES_ALL and # INCLUDES_ONLY can't be expressed through pills alone, so they are the # only M2M modifiers with explicit UI. _M2M_MODIFIERS: list[LabeledOption] = [ ("INCLUDES_ALL", "(All)"), ("INCLUDES_ONLY", "(Only)"), ] def _modifier_options( nullable: bool, m2m_modifiers: list[LabeledOption] | None = None ) -> list[LabeledOption]: """Pinned pseudo-options rendered at the top of the dropdown. Always includes ``(Any)`` (NOT_NULL); adds ``(None)`` (IS_NULL) when ``nullable`` is True. When ``m2m_modifiers`` is given (M2M fields only), appends those rows (e.g. ``(All)`` / ``(Only)``).""" options: list[LabeledOption] = [("NOT_NULL", "(Any)")] if nullable: options.append(("IS_NULL", "(None)")) if m2m_modifiers: options.extend(m2m_modifiers) return options def _split_modifier(modifier: str, has_m2m: bool = False) -> str: """Return the modifier value to surface as the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) are always surfaced. Non-presence modifiers (INCLUDES / INCLUDES_ALL / INCLUDES_ONLY) only need a pill on M2M fields — otherwise the modifier is just the implicit default. """ if modifier in _PRESENCE_MODIFIERS or not has_m2m: return modifier if modifier: return modifier return "" def _enum_filter( field_name: str, options, choice: FilterChoice, *, nullable ) -> SafeText: """A FilterSelect over a small, fully pre-rendered option set (enum field). Enum fields are single-valued, so no M2M modifiers (all/only are meaningless); only the presence modifier is surfaced. """ options_str = [(str(value), label) for value, label in options] included = [ (value, _find_label(options_str, value)) for value, _label in choice.selected ] excluded = [ (value, _find_label(options_str, value)) for value, _label in choice.excluded ] modifier = _split_modifier(choice.modifier) return FilterSelect( field_name=field_name, options=options_str, included=included, excluded=excluded, modifier=modifier, modifier_options=_modifier_options(nullable), ) def _model_filter( field_name: str, choice: FilterChoice, *, search_url, nullable, m2m_modifiers: list[LabeledOption] | None = None, ) -> SafeText: """A FilterSelect backed by a search endpoint. Labels are embedded in the filter JSON (Stash-style), so pills render directly from ``choice`` with no DB round-trip. Pass ``m2m_modifiers`` for many-to-many fields to surface ``(All)`` / ``(Only)`` pseudo-options in the dropdown alongside the presence options. """ modifier = _split_modifier(choice.modifier, has_m2m=bool(m2m_modifiers)) return FilterSelect( field_name=field_name, included=[(value, label or value) for value, label in choice.selected], excluded=[(value, label or value) for value, label in choice.excluded], modifier=modifier, modifier_options=_modifier_options(nullable, m2m_modifiers), search_url=search_url, prefetch=DEFAULT_PREFETCH, ) def _filter_mins_to_hrs(val) -> str: if val is None or val == "" or val == 0: return "" try: mins = int(val) except (TypeError, ValueError): return "" if mins == 0: return "" hrs = mins / 60 return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}" def _filter_field(label: str, widget) -> SafeText: """A labelled filter field:
{widget}
.""" return Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Label( attributes=[("class", _FILTER_LABEL_CLASS)], children=[label], ), widget, ], ) def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: return Label( attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[ Component( tag_name="input", attributes=[ ("type", "checkbox"), ("name", name), ("value", "1"), *([("checked", "true")] if checked else []), ("class", _FILTER_CHECKBOX_CLASS), ], ), label, ], ) # SVG icons for the mode toggle (shared across all RangeSliders) _RANGE_ICON_SVG = ( '' '' '' '' "" ) _POINT_ICON_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( 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=[ Label( attributes=[ ("class", _FILTER_LABEL_CLASS), ("for", min_input_id), ], 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 ""), ), ], ), 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=[ Span( attributes=[ ( "class", "range-mode-icon-range" + (" hidden" if point_mode else ""), ), ], children=[mark_safe(_RANGE_ICON_SVG)], ), 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)), ], children=[ Component( tag_name="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%"), ], ), ], ), ], ) _FILTER_FORM_ID = "filter-bar-form" _FILTER_INPUT_ID = "filter-json-input" def _filter_collapse_button() -> SafeText: return Component( tag_name="button", attributes=[ ("type", "button"), ( "onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", ), ( "class", "flex items-center gap-2 text-sm font-medium text-body " "hover:text-heading mb-2", ), ], children=[ mark_safe( '' ), "Filters", ], ) def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: return Component( tag_name="div", attributes=[("class", "flex gap-3 items-center")], children=[ Component( tag_name="button", attributes=[ ("type", "submit"), ( "class", "px-4 py-2 text-sm font-medium text-white bg-brand " "rounded-lg hover:bg-brand-strong focus:ring-4 " "focus:ring-brand-medium", ), ], children=["Apply"], ), Component( tag_name="button", attributes=[ ("type", "button"), ( "onclick", f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')", ), ( "class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white " "border border-gray-200 rounded-lg hover:bg-gray-100 " "dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 " "dark:hover:bg-gray-700 dark:hover:text-white", ), ], children=["Clear"], ), Span( attributes=[ ("class", "flex gap-2 items-center"), ("id", "save-preset-area"), ], children=[ Component( tag_name="input", attributes=[ ("type", "text"), ("id", "preset-name-input"), ("placeholder", "Preset name..."), ( "class", "hidden px-3 py-2 text-sm rounded-lg border " "border-default-medium bg-neutral-secondary-medium " "text-heading focus:ring-brand focus:border-brand", ), ], ), Component( tag_name="button", attributes=[ ("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ( "class", "px-4 py-2 text-sm font-medium text-gray-900 " "bg-white border border-gray-200 rounded-lg " "hover:bg-gray-100 dark:bg-gray-800 " "dark:border-gray-600 dark:text-gray-400 " "dark:hover:bg-gray-700 dark:hover:text-white", ), ], children=["Save Preset"], ), Component( tag_name="button", attributes=[ ("type", "button"), ("id", "confirm-save-preset-btn"), ( "onclick", f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')", ), ( "class", "hidden px-4 py-2 text-sm font-medium text-white " "bg-green-700 rounded-lg hover:bg-green-800 " "focus:ring-4 focus:ring-green-300", ), ], children=["Save"], ), ], ), Component( tag_name="div", attributes=[ ("id", "preset-dropdown"), ("class", "relative"), ("data-preset-list-url", preset_list_url), ], children=[ Span( attributes=[("class", "text-sm text-body")], children=["Loading presets..."], ), ], ), ], ) def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: """Shared collapsible filter-bar chrome. `fields` is the per-entity body (grids, sliders, checkboxes); the shell adds the collapse toggle, the form, the hidden filter-json input and the Apply/Clear/preset action row.""" return Component( tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[ _filter_collapse_button(), Component( tag_name="div", attributes=[ ("id", "filter-bar-body"), ( "class", "hidden border border-default-medium rounded-base p-4 " "bg-neutral-secondary-medium/50", ), ], children=[ Component( tag_name="form", attributes=[ ("id", _FILTER_FORM_ID), ("onsubmit", "return applyFilterBar(event)"), ], children=[ Component( tag_name="input", attributes=[ ("type", "hidden"), ("id", _FILTER_INPUT_ID), ("name", "filter"), # NB: Component escapes attribute values, so the # raw JSON is passed through (no double-escape). ("value", filter_json), ], ), *fields, _filter_action_row(preset_list_url, preset_save_url), ], ), ], ), ], ) def FilterBar( filter_json: str = "", status_options: list[LabeledOption] | None = None, preset_list_url: str = "", preset_save_url: str = "", ) -> SafeText: """Collapsible filter bar for the Game list.""" from games.models import Game if status_options is None: status_options = [(s.value, s.label) for s in Game.Status] existing = _filter_parse(filter_json) status_choice = _filter_get_choice(existing, "status") platform_choice = _filter_get_choice(existing, "platform") year_min, year_max = _parse_range(existing, "year_released") mastered_value = _parse_bool(existing, "mastered") playtime = existing.get("playtime_minutes", {}) if isinstance(playtime, dict): playtime_min = _filter_mins_to_hrs(playtime.get("value", "")) playtime_max = _filter_mins_to_hrs(playtime.get("value2", "")) else: playtime_min = "" playtime_max = "" try: year_aggregate = Game.objects.aggregate( year_min=models.Min("year_released"), year_max=models.Max("year_released") ) except Exception: year_aggregate = {} try: playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime")) except Exception: playtime_aggregate = {} year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970) year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030) playtime_range_max = ( int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600) if playtime_aggregate.get("playtime_max") else 200 ) fields = [ Component( tag_name="div", attributes=[("class", _FILTER_GRID_CLASS)], children=[ _filter_field( "Status", _enum_filter( "status", status_options, status_choice, nullable=not Game._meta.get_field("status").has_default(), ), ), _filter_field( "Platform", _model_filter( "platform", platform_choice, search_url="/api/platforms/search", nullable=Game._meta.get_field("platform").null, ), ), ], ), RangeSlider( label="Year", input_name_prefix="filter-year", min_value=year_min, max_value=year_max, range_min=year_range_min, range_max=year_range_max, min_placeholder="e.g. 2020", max_placeholder="e.g. 2024", ), Component( tag_name="div", attributes=[("class", "flex items-end gap-4 mb-4")], children=[ _filter_checkbox("filter-mastered", "Mastered", mastered_value), ], ), RangeSlider( label="Playtime", input_name_prefix="filter-playtime", min_value=playtime_min, max_value=playtime_max, range_min=0, range_max=playtime_range_max, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 100", ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def _find_label(options: list[LabeledOption], value: str) -> str: for v, label in options: if str(v) == str(value): return label return value def SessionFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the Session list.""" from games.models import Game, Session existing = _filter_parse(filter_json) game_choice = _filter_get_choice(existing, "game") device_choice = _filter_get_choice(existing, "device") duration_min, duration_max = _parse_range(existing, "duration_minutes") duration_min = _filter_mins_to_hrs(duration_min) duration_max = _filter_mins_to_hrs(duration_max) emulated_value = _parse_bool(existing, "emulated") is_active_value = _parse_bool(existing, "is_active") try: duration_aggregate = Session.objects.aggregate( duration_max=models.Max("duration_total") ) 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: duration_range_max = 200 fields = [ Component( tag_name="div", attributes=[("class", _FILTER_GRID_CLASS)], children=[ _filter_field( "Game", _model_filter( "game", game_choice, search_url="/api/games/search", nullable=not Game._meta.get_field("name").has_default(), ), ), _filter_field( "Device", _model_filter( "device", device_choice, search_url="/api/devices/search", nullable=Session._meta.get_field("device").null, ), ), ], ), RangeSlider( 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( tag_name="div", attributes=[("class", "flex gap-4 mb-4")], children=[ _filter_checkbox("filter-emulated", "Emulated", emulated_value), _filter_checkbox("filter-active", "Active", is_active_value), ], ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def PurchaseFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the Purchase list.""" from games.models import Purchase type_options = Purchase.TYPES ownership_options = Purchase.OWNERSHIP_TYPES existing = _filter_parse(filter_json) game_choice = _filter_get_choice(existing, "games") platform_choice = _filter_get_choice(existing, "platform") type_choice = _filter_get_choice(existing, "type") ownership_choice = _filter_get_choice(existing, "ownership_type") price_min, price_max = _parse_range(existing, "price") is_refunded_value = _parse_bool(existing, "is_refunded") try: price_aggregate = Purchase.objects.aggregate( 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: price_range_min, price_range_max = 0, 100 num_min, num_max = _parse_range(existing, "num_purchases") try: num_aggregate = Purchase.objects.aggregate( num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases") ) num_range_min = max(int(num_aggregate.get("num_min") or 0), 0) num_range_max = max(int(num_aggregate.get("num_max") or 10), 1) except Exception: num_range_min, num_range_max = 0, 10 fields = [ Component( tag_name="div", attributes=[("class", _FILTER_GRID_CLASS)], children=[ _filter_field( "Game", _model_filter( "games", game_choice, search_url="/api/games/search", nullable=False, # games is many-to-many on Purchase: (All) means # INCLUDES_ALL ("purchase linked to every selected # game"); (Only) means INCLUDES_ONLY. m2m_modifiers=_M2M_MODIFIERS, ), ), _filter_field( "Platform", _model_filter( "platform", platform_choice, search_url="/api/platforms/search", nullable=Purchase._meta.get_field("platform").null, ), ), _filter_field( "Type", _enum_filter( "type", type_options, type_choice, nullable=not Purchase._meta.get_field("type").has_default(), ), ), _filter_field( "Ownership", _enum_filter( "ownership_type", ownership_options, ownership_choice, nullable=not Purchase._meta.get_field( "ownership_type" ).has_default(), ), ), ], ), Component( tag_name="div", attributes=[("class", "flex items-end gap-4 mb-4")], children=[ _filter_checkbox("filter-refunded", "Refunded", is_refunded_value), ], ), RangeSlider( 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", ), RangeSlider( label="Games in purchase", input_name_prefix="filter-num-purchases", min_value=num_min, max_value=num_max, range_min=num_range_min, range_max=num_range_max, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 5", ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)