diff --git a/_cf.py b/_cf.py deleted file mode 100644 index 2532dd1..0000000 --- a/_cf.py +++ /dev/null @@ -1,1950 +0,0 @@ -import hashlib -from functools import lru_cache -from typing import Any - -from django.db import models -from django.middleware.csrf import get_token -from django.template.defaultfilters import floatformat -from django.templatetags.static import static -from django.urls import reverse -from django.utils.html import conditional_escape, escape -from django.utils.safestring import SafeText, mark_safe - -from common.icons import get_icon -from common.utils import truncate -from games.models import Game, Purchase, Session - -HTMLAttribute = tuple[str, str | int | bool] -HTMLTag = str - -_COLOR_CLASSES = { - "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", - "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", - "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", - "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", -} - -_SIZE_CLASSES = { - "xs": "px-3 py-2 text-xs shadow-xs", - "sm": "px-3 py-2 text-sm", - "base": "px-5 py-2.5 text-sm", - "lg": "px-5 py-3 text-base", - "xl": "px-6 py-3.5 text-base", -} - - -@lru_cache(maxsize=4096) -def _render_element( - tag_name: str, - attrs_key: tuple[tuple[str, str], ...], - children_key: tuple[tuple[str, bool], ...], -) -> str: - """Pure, memoized HTML builder behind `Component`. - - Inputs are fully hashable and fully determine the output, so identical - elements are rendered once. `attrs_key` is (name, stringified value) pairs - (attribute values are always escaped). `children_key` is (child, is_safe) - pairs: SafeText children pass through, plain strings are escaped. The - `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` - and an unsafe ``""`` (equal as strings) would collide and one would - render with the wrong escaping. - """ - children_blob = "\n".join( - child if is_safe else escape(child) for child, is_safe in children_key - ) - if attrs_key: - attributes_blob = " " + " ".join( - f'{name}="{escape(value)}"' for name, value in attrs_key - ) - else: - attributes_blob = "" - return f"<{tag_name}{attributes_blob}>{children_blob}" - - -def Component( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - tag_name: str = "", -) -> SafeText: - """Render an HTML element. Attribute values are always escaped; children are - escaped unless they are `SafeText` (so nested components pass through), - preventing accidental HTML injection. Rendering is memoized via - `_render_element`.""" - attributes = attributes or [] - children = children or [] - if not tag_name: - raise ValueError("tag_name is required.") - if isinstance(children, str): - children = [children] - attrs_key = tuple((name, str(value)) for name, value in attributes) - children_key = tuple((child, isinstance(child, SafeText)) for child in children) - return mark_safe(_render_element(tag_name, attrs_key, children_key)) - - -def randomid(seed: str = "", content: str = "", length: int = 10) -> str: - if not seed and not content: - return seed - hash_input = f"{seed}:{content}" if seed else content - content_hash = hashlib.sha1(hash_input.encode()).hexdigest() - base = ( - content_hash[:length] - if not seed - else content_hash[: max(0, length - len(seed))] - ) - return seed + base - - -def _popover_html( - id: str, - popover_content: str, - wrapped_content: str = "", - wrapped_classes: str = "", - slot: str = "", -) -> SafeText: - """Generate popover HTML using Component(tag_name=...). - - Single source of truth for popover HTML structure. - Used by Popover() and the python_popover template tag bridge. - """ - display_content = wrapped_content if wrapped_content else slot - - span = Component( - tag_name="span", - attributes=[ - ("data-popover-target", id), - ("class", wrapped_classes), - ], - children=[display_content] if display_content else [], - ) - - popover_tooltip_class = ( - "absolute z-10 invisible inline-block text-sm text-white " - "transition-opacity duration-300 bg-white border border-purple-200 " - "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " - "dark:bg-purple-800" - ) - - div = Component( - tag_name="div", - attributes=[ - ("data-popover", ""), - ("id", id), - ("role", "tooltip"), - ("class", popover_tooltip_class), - ], - children=[ - Component( - tag_name="div", - attributes=[("class", "px-3 py-2")], - children=[popover_content], - ), - Component(tag_name="div", attributes=[("data-popper-arrow", "")]), - mark_safe( # nosec — intentional HTML comment for Tailwind JIT - "" - ), - Component( - tag_name="span", - attributes=[("class", "hidden decoration-dotted")], - ), - ], - ) - - return mark_safe(span + "\n" + div) - - -def Popover( - popover_content: str, - wrapped_content: str = "", - wrapped_classes: str = "", - children: list[HTMLTag] | None = None, - attributes: list[HTMLAttribute] | None = None, - id: str = "", -) -> str: - children = children or [] - if not wrapped_content and not children: - raise ValueError("One of wrapped_content or children is required.") - if not id: - id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") - - slot = mark_safe("\n".join(children)) - return _popover_html( - id=id, - popover_content=popover_content, - wrapped_content=wrapped_content, - wrapped_classes=wrapped_classes, - slot=slot, - ) - - -def PopoverTruncated( - input_string: str, - popover_content: str = "", - popover_if_not_truncated: bool = False, - length: int = 30, - ellipsis: str = "…", - endpart: str = "", -) -> str: - """ - Returns `input_string` truncated after `length` of characters - and displays the untruncated text in a popover HTML element. - The truncated text ends in `ellipsis`, and optionally - an always-visible `endpart` can be specified. - `popover_content` can be specified if: - 1. It needs to be always displayed regardless if text is truncated. - 2. It needs to differ from `input_string`. - """ - if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: - return Popover( - wrapped_content=truncated, - popover_content=popover_content if popover_content else input_string, - ) - else: - if popover_content and popover_if_not_truncated: - return Popover( - wrapped_content=input_string, - popover_content=popover_content if popover_content else "", - ) - else: - return input_string - - -def A( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - url_name: str | None = None, - href: str | None = None, -) -> SafeText: - """ - Returns an anchor tag. - - Accepts one of two mutually-exclusive URL specifications: - - url_name: URL pattern name, resolved via reverse() - - href: Literal path string passed through as-is - """ - attributes = attributes or [] - children = children or [] - if url_name is not None and href is not None: - raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") - - additional_attributes = [] - if url_name is not None: - additional_attributes = [("href", reverse(url_name))] - elif href is not None: - additional_attributes = [("href", href)] - return Component( - tag_name="a", attributes=attributes + additional_attributes, children=children - ) - - -def Button( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - size: str = "base", - icon: bool = False, - color: str = "blue", - type: str = "button", - hx_get: str = "", - hx_target: str = "", - hx_swap: str = "", - title: str = "", - onclick: str = "", - name: str = "", -) -> SafeText: - attributes = attributes or [] - children = children or [] - - # Separate custom class from other generic attributes - custom_class = "" - other_attrs: list[HTMLAttribute] = [] - for attr_name, attr_value in attributes: - if attr_name == "class": - custom_class = str(attr_value) - else: - other_attrs.append((attr_name, attr_value)) - - # Build class string: custom class first, then base, color, size, icon - class_parts: list[str] = [] - if custom_class: - class_parts.append(custom_class) - class_parts.append( - "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " - "font-medium mb-2 me-2 rounded-base" - ) - class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) - class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) - if icon: - class_parts.append("inline-flex text-center items-center gap-2") - - # Build the full attribute list for the button tag - button_attrs: list[HTMLAttribute] = [ - ("type", type), - ("class", " ".join(class_parts)), - ] - if hx_get: - button_attrs.append(("hx-get", hx_get)) - if hx_target: - button_attrs.append(("hx-target", hx_target)) - if hx_swap: - button_attrs.append(("hx-swap", hx_swap)) - if title: - button_attrs.append(("title", title)) - if onclick: - button_attrs.append(("onclick", onclick)) - if name: - button_attrs.append(("name", name)) - button_attrs.extend(other_attrs) - - return Component( - tag_name="button", - attributes=button_attrs, - children=children, - ) - - -_GROUP_BUTTON_COLORS = { - "gray": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " - "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " - "dark:bg-gray-800 dark:border-gray-700 dark:text-white " - "dark:hover:text-white dark:hover:bg-gray-700 " - "dark:focus:ring-blue-500 dark:focus:text-white" - ), - "red": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " - "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " - "dark:bg-gray-800 dark:border-gray-700 dark:text-white " - "dark:hover:text-white dark:hover:border-red-700 " - "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" - ), - "green": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-green-500 hover:border-green-600 " - "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " - "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " - "dark:text-white dark:hover:text-white dark:hover:border-green-700 " - "dark:hover:bg-green-600 dark:focus:ring-green-500 " - "dark:focus:text-white" - ), -} - - -def _button_group_button( - href: str, - slot: str, - color: str = "gray", - title: str = "", - hx_get: str = "", - hx_target: str = "", -) -> SafeText: - """Generate a single button-group button (inner " - "" - ) - - -# ── Filter bar (shared shell + per-entity field builders) ─────────────────── - -_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" -_FILTER_INPUT_CLASS = ( - "block w-full rounded-base border border-default-medium " - "bg-neutral-secondary-medium text-sm text-heading p-2 " - "focus:ring-brand focus:border-brand" -) -_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 _filter_get_choice(existing: dict, field: str) -> tuple[list[str], list[str], str]: - raw = existing.get(field, {}) - if not isinstance(raw, dict): - return [], [], "" - val = raw.get("value", []) - excl = raw.get("excludes", []) - mod = raw.get("modifier", "") - if isinstance(val, str): - val = [val] - if isinstance(excl, str): - excl = [excl] - return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or "" - - -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=[ - Component( - tag_name="label", - attributes=[("class", _FILTER_LABEL_CLASS)], - children=[label], - ), - widget, - ], - ) - - -def _filter_number(label, name, value="", placeholder="") -> SafeText: - return _filter_field( - label, - Component( - tag_name="input", - attributes=[ - ("type", "number"), - ("name", escape(name)), - ("id", escape(name)), - ("value", escape(value)), - ("placeholder", escape(placeholder)), - ("class", _FILTER_INPUT_CLASS), - ], - ), - ) - - -def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: - return Component( - tag_name="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, - ], - ) - - -def _filter_range_inputs(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"): - """Twin slider (used by the game filter bar).""" - mv = min_v or str(dmin) - xv = max_v or str(dmax) - return Component( - tag_name="div", - attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")], - children=[ - mark_safe( - f'' - f'' - ), - ], - ) - - -def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"): - """Handle-based slider (used by the session & purchase filter bars).""" - return Component( - tag_name="div", - attributes=[ - ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), - ("data-min", str(lo)), - ("data-max", str(hi)), - ("data-step", str(step)), - ], - children=[ - mark_safe( - '
' - + f'
' - ), - ], - ) - - -_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"], - ), - Component( - tag_name="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=[ - Component( - tag_name="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[tuple[str, str]] | None = None, - platform_options: list[tuple[int, str]] | None = None, - preset_list_url: str = "", - preset_save_url: str = "", -) -> SafeText: - """Collapsible filter bar for the Game list.""" - from games.models import Game, Platform - - if status_options is None: - status_options = [(s.value, s.label) for s in Game.Status] - if platform_options is None: - platform_options = list( - Platform.objects.order_by("name").values_list("id", "name") - ) - - existing = _filter_parse(filter_json) - status_sel, status_excl, status_mod = _filter_get_choice(existing, "status") - plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform") - plat_opts_str = [(str(k), v) for k, v in platform_options] - - year_rel = existing.get("year_released", {}) - year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else "" - 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_min = ( - _filter_mins_to_hrs(playtime.get("value", "")) - if isinstance(playtime, dict) - else "" - ) - playtime_max = ( - _filter_mins_to_hrs(playtime.get("value2", "")) - if isinstance(playtime, dict) - else "" - ) - - try: - year_agg = Game.objects.aggregate( - yr_min=models.Min("year_released"), yr_max=models.Max("year_released") - ) - except Exception: - year_agg = {} - try: - pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime")) - except Exception: - pt_agg = {} - yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970) - yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030) - pt_data_max = ( - int((pt_agg.get("pt_max") or 0).total_seconds() / 3600) - if pt_agg.get("pt_max") - else 200 - ) - - fields = [ - Component( - tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], - children=[ - _filter_field( - "Status", - SelectableFilter( - "status", - status_options, - status_sel, - status_excl, - status_mod, - nullable=not Game._meta.get_field("status").has_default(), - ), - ), - _filter_field( - "Platform", - SelectableFilter( - "platform", - plat_opts_str, - plat_sel, - plat_excl, - plat_mod, - 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( - "year-range", - "filter-year-min", - "filter-year-max", - year_min, - year_max, - yr_data_min, - yr_data_max, - ), - Component( - tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], - children=[ - _filter_number( - "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) - ], - ), - ], - ), - _filter_range_inputs( - "playtime-range", - "filter-playtime-min", - "filter-playtime-max", - playtime_min or "0", - playtime_max or str(pt_data_max), - 0, - pt_data_max, - ), - ] - return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) - - -# ── SelectableFilter widget ──────────────────────────────────────────────── - - -def SelectableFilter( - field_name: str, - options: list[tuple[str, str]], - selected: list[str] | None = None, - excluded: list[str] | None = None, - modifier: str = "", - nullable: bool = True, -) -> "SafeText": - """Stash-style selectable filter with search, include/exclude, modifier tags.""" - selected = selected or [] - excluded = excluded or [] - - active_mod_html = "" - inactive_mod_html = "" - mod_opts = [("NOT_NULL", "(Any)")] - if nullable: - mod_opts.append(("IS_NULL", "(None)")) - for mod_val, mod_label in mod_opts: - if modifier == mod_val: - active_mod_html = ( - f'' - f"{mod_label} " - ) - else: - inactive_mod_html += ( - f'
' - f'{mod_label}
' - ) - - selected_html = "" - for val in selected: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2713 {escape(label)}' - f' ' - ) - for val in excluded: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2717 {escape(label)}' - f' ' - ) - - options_html = "" - for val, label in options: - options_html += ( - f'
' - f'{escape(label)}' - f'' - f'' - f'' - f"
" - ) - - return Component( - tag_name="div", - attributes=[ - ( - "class", - "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", - ), - ("data-selectable-filter", field_name), - *([("data-modifier", modifier)] if modifier else []), - ], - children=[ - Component( - tag_name="div", - attributes=[ - ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), - ], - children=[mark_safe(active_mod_html + selected_html)], - ), - Component( - tag_name="input", - attributes=[ - ("type", "text"), - ( - "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"), - ], - ), - Component( - tag_name="div", - attributes=[ - ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"), - ], - children=[mark_safe(inactive_mod_html + options_html)], - ), - ], - ) - - -def _find_label(options: list[tuple[str, str]], 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 Device, Game, Session - - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - dev_opts = [ - (str(k), v) - for k, v in Device.objects.order_by("name").values_list("id", "name") - ] - existing = _filter_parse(filter_json) - gs, ge, gm = _filter_get_choice(existing, "game") - ds, de, dm = _filter_get_choice(existing, "device") - - dur = existing.get("duration_minutes", {}) - dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else "" - dmax = _filter_mins_to_hrs(dur.get("value2", "")) if isinstance(dur, dict) else "" - em = ( - existing.get("emulated", {}).get("value", False) - 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: - a = Session.objects.aggregate(m=models.Max("duration_total")) - ddm = max( - int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1 - ) - except Exception: - ddm = 200 - - fields = [ - Component( - tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], - children=[ - _filter_field( - "Game", - SelectableFilter( - "game", - game_opts, - gs, - ge, - gm, - nullable=not Game._meta.get_field("name").has_default(), - ), - ), - _filter_field( - "Device", - SelectableFilter( - "device", - dev_opts, - ds, - de, - dm, - 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( - "dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm - ), - Component( - tag_name="div", - attributes=[("class", "flex gap-4 mb-4")], - children=[ - _filter_checkbox("filter-emulated", "Emulated", em), - _filter_checkbox("filter-active", "Active", ac), - ], - ), - ] - 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 Game, Platform, Purchase - - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - plat_opts = [ - (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) - gs, ge, gm = _filter_get_choice(existing, "games") - ps, pe, pm = _filter_get_choice(existing, "platform") - ts, te, tm = _filter_get_choice(existing, "type") - os_, oe, om = _filter_get_choice(existing, "ownership_type") - price = existing.get("price", {}) - pmin = str(price.get("value", "")) if isinstance(price, dict) else "" - 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: - a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price")) - plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1) - except Exception: - plo, phi = 0, 100 - - fields = [ - Component( - tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], - children=[ - _filter_field( - "Game", - SelectableFilter("games", game_opts, gs, ge, gm, nullable=False), - ), - _filter_field( - "Platform", - SelectableFilter( - "platform", - plat_opts, - ps, - pe, - pm, - nullable=Purchase._meta.get_field("platform").null, - ), - ), - _filter_field( - "Type", - SelectableFilter( - "type", - type_opts, - ts, - te, - tm, - nullable=not Purchase._meta.get_field("type").has_default(), - ), - ), - _filter_field( - "Ownership", - SelectableFilter( - "ownership_type", - own_opts, - os_, - oe, - om, - nullable=not Purchase._meta.get_field( - "ownership_type" - ).has_default(), - ), - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], - children=[ - _filter_number("Price Min", "filter-price-min", pmin, "0.00"), - _filter_number("Price Max", "filter-price-max", pmax, "100.00"), - _filter_checkbox("filter-refunded", "Refunded", rf), - ], - ), - _filter_range_handles( - "price-range", "filter-price-min", "filter-price-max", plo, phi - ), - ] - return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) diff --git a/common/components/filters.py b/common/components/filters.py index b7d9429..6003c57 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -1,5 +1,7 @@ """Stash-style filter bars and the SelectableFilter widget.""" +from typing import NamedTuple + from django.db import models from django.utils.html import escape 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 +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" @@ -38,18 +48,52 @@ def _filter_parse(filter_json: str) -> dict: 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, {}) if not isinstance(raw, dict): - return [], [], "" - val = raw.get("value", []) - excl = raw.get("excludes", []) - mod = raw.get("modifier", "") - if isinstance(val, str): - val = [val] - if isinstance(excl, str): - excl = [excl] - return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or "" + return FilterChoice([], [], "") + value = raw.get("value", []) + excluded = raw.get("excludes", []) + modifier = raw.get("modifier", "") + if isinstance(value, str): + value = [value] + if isinstance(excluded, str): + excluded = [excluded] + 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: @@ -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"): - """Twin slider (used by the game filter bar).""" - mv = min_v or str(dmin) - xv = max_v or str(dmax) +# 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", f"range-slider {cls} relative h-6 mt-1 mb-2")], + attributes=[("class", "range-slider-block mb-4")], children=[ - mark_safe( - f'' - f'' + # ── Label row ── + Component( + tag_name="div", + attributes=[("class", "flex items-center gap-2 mb-1")], + children=[ + Component( + tag_name="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 ""), + ), + ], + ), + 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)], + ), + ], + ), + ], ), - ], - ) - - -def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"): - """Handle-based slider (used by the session & purchase filter bars).""" - return Component( - tag_name="div", - attributes=[ - ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), - ("data-min", str(lo)), - ("data-max", str(hi)), - ("data-step", str(step)), - ], - children=[ - mark_safe( - '
' - + f'
' + # ── 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%"), + ], + ), + ], ), ], ) @@ -371,50 +582,38 @@ def FilterBar( if status_options is None: status_options = [(s.value, s.label) for s in Game.Status] if platform_options is None: - platform_options = list( - Platform.objects.order_by("name").values_list("id", "name") - ) + platform_options = _get_filter_options(Platform) existing = _filter_parse(filter_json) - status_sel, status_excl, status_mod = _filter_get_choice(existing, "status") - plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform") - plat_opts_str = [(str(k), v) for k, v in platform_options] + status_choice = _filter_get_choice(existing, "status") + platform_choice = _filter_get_choice(existing, "platform") + platform_options_str = [(str(pk), name) for pk, name in platform_options] - year_rel = existing.get("year_released", {}) - year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else "" - 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 - ) + year_min, year_max = _parse_range(existing, "year_released") + mastered_value = _parse_bool(existing, "mastered") playtime = existing.get("playtime_minutes", {}) - playtime_min = ( - _filter_mins_to_hrs(playtime.get("value", "")) - if isinstance(playtime, dict) - else "" - ) - playtime_max = ( - _filter_mins_to_hrs(playtime.get("value2", "")) - if isinstance(playtime, dict) - else "" - ) + 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_agg = Game.objects.aggregate( - yr_min=models.Min("year_released"), yr_max=models.Max("year_released") + year_aggregate = Game.objects.aggregate( + year_min=models.Min("year_released"), year_max=models.Max("year_released") ) except Exception: - year_agg = {} + year_aggregate = {} try: - pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime")) + playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime")) except Exception: - pt_agg = {} - yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970) - yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030) - pt_data_max = ( - int((pt_agg.get("pt_max") or 0).total_seconds() / 3600) - if pt_agg.get("pt_max") + 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 ) @@ -428,9 +627,9 @@ def FilterBar( SelectableFilter( "status", status_options, - status_sel, - status_excl, - status_mod, + status_choice.selected, + status_choice.excluded, + status_choice.modifier, nullable=not Game._meta.get_field("status").has_default(), ), ), @@ -438,61 +637,155 @@ def FilterBar( "Platform", SelectableFilter( "platform", - plat_opts_str, - plat_sel, - plat_excl, - plat_mod, + platform_options_str, + platform_choice.selected, + platform_choice.excluded, + platform_choice.modifier, 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( - "year-range", - "filter-year-min", - "filter-year-max", - year_min, - year_max, - yr_data_min, - yr_data_max, + 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", _FILTER_GRID_CLASS)], + attributes=[("class", "flex items-end gap-4 mb-4")], children=[ - _filter_number( - "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) - ], - ), + _filter_checkbox("filter-mastered", "Mastered", mastered_value), ], ), - _filter_range_inputs( - "playtime-range", - "filter-playtime-min", - "filter-playtime-max", - playtime_min or "0", - playtime_max or str(pt_data_max), - 0, - pt_data_max, + 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 _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( field_name: str, options: list[tuple[str, str]], @@ -505,87 +798,70 @@ def SelectableFilter( selected = selected or [] excluded = excluded or [] - active_mod_html = "" - inactive_mod_html = "" - mod_opts = [("NOT_NULL", "(Any)")] + modifier_options = [("NOT_NULL", "(Any)")] if nullable: - mod_opts.append(("IS_NULL", "(None)")) - for mod_val, mod_label in mod_opts: - if modifier == mod_val: - active_mod_html = ( - f'' - f"{mod_label} " + modifier_options.append(("IS_NULL", "(None)")) + + active_modifier_tag = "" + inactive_modifier_options: list[SafeText] = [] + for modifier_value, modifier_label in modifier_options: + if modifier == modifier_value: + active_modifier_tag = _selectable_filter_modifier_tag( + modifier_value, modifier_label ) else: - inactive_mod_html += ( - f'
' - f'{mod_label}
' + inactive_modifier_options.append( + _selectable_filter_modifier_option(modifier_value, modifier_label) ) - selected_html = "" - for val in selected: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2713 {escape(label)}' - f' ' + selected_tags: list[SafeText] = [] + for value in selected: + selected_tags.append( + _selectable_filter_tag(value, _find_label(options, value), excluded=False) ) - for val in excluded: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2717 {escape(label)}' - f' ' + for value in excluded: + selected_tags.append( + _selectable_filter_tag(value, _find_label(options, value), excluded=True) ) - options_html = "" - for val, label in options: - options_html += ( - f'
' - f'{escape(label)}' - f'' - f'' - f'' - f"
" - ) + option_rows: list[SafeText] = [] + for value, label in options: + option_rows.append(_selectable_filter_option(value, label)) + + selected_area_children: list[SafeText] = [] + if active_modifier_tag: + selected_area_children.append(active_modifier_tag) + selected_area_children.extend(selected_tags) + + options_area_children: list[SafeText] = [] + options_area_children.extend(inactive_modifier_options) + options_area_children.extend(option_rows) return Component( tag_name="div", attributes=[ - ( - "class", - "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", - ), + ("class", "sf-container"), ("data-selectable-filter", field_name), *([("data-modifier", modifier)] if modifier else []), ], children=[ Component( tag_name="div", - attributes=[ - ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), - ], - children=[mark_safe(active_mod_html + selected_html)], + attributes=[("class", "sf-selected")], + children=selected_area_children, ), Component( tag_name="input", attributes=[ ("type", "text"), - ( - "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", - ), + ("class", "sf-search"), ("placeholder", "Search\u2026"), ], ), Component( tag_name="div", - attributes=[ - ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"), - ], - children=[mark_safe(inactive_mod_html + options_html)], + attributes=[("class", "sf-options")], + children=options_area_children, ), ], ) @@ -604,37 +880,29 @@ def SessionFilterBar( """Collapsible filter bar for the Session list.""" from games.models import Device, Game, Session - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - dev_opts = [ - (str(k), v) - for k, v in Device.objects.order_by("name").values_list("id", "name") - ] + game_options = _get_filter_options(Game) + device_options = _get_filter_options(Device) existing = _filter_parse(filter_json) - gs, ge, gm = _filter_get_choice(existing, "game") - ds, de, dm = _filter_get_choice(existing, "device") + game_choice = _filter_get_choice(existing, "game") + device_choice = _filter_get_choice(existing, "device") - dur = existing.get("duration_minutes", {}) - dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else "" - dmax = _filter_mins_to_hrs(dur.get("value2", "")) if isinstance(dur, dict) else "" - em = ( - existing.get("emulated", {}).get("value", False) - 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 - ) + 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: - a = Session.objects.aggregate(m=models.Max("duration_total")) - ddm = max( - int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1 + 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: - ddm = 200 + duration_range_max = 200 fields = [ Component( @@ -645,10 +913,10 @@ def SessionFilterBar( "Game", SelectableFilter( "game", - game_opts, - gs, - ge, - gm, + game_options, + game_choice.selected, + game_choice.excluded, + game_choice.modifier, nullable=not Game._meta.get_field("name").has_default(), ), ), @@ -656,30 +924,31 @@ def SessionFilterBar( "Device", SelectableFilter( "device", - dev_opts, - ds, - de, - dm, + device_options, + device_choice.selected, + device_choice.excluded, + device_choice.modifier, 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( - "dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm + 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", em), - _filter_checkbox("filter-active", "Active", ac), + _filter_checkbox("filter-emulated", "Emulated", emulated_value), + _filter_checkbox("filter-active", "Active", is_active_value), ], ), ] @@ -692,33 +961,25 @@ def PurchaseFilterBar( """Collapsible filter bar for the Purchase list.""" from games.models import Game, Platform, Purchase - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - plat_opts = [ - (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] + game_options = _get_filter_options(Game) + platform_options = _get_filter_options(Platform) + type_options = [(value, label) for value, label in Purchase.TYPES] + ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES] existing = _filter_parse(filter_json) - gs, ge, gm = _filter_get_choice(existing, "games") - ps, pe, pm = _filter_get_choice(existing, "platform") - ts, te, tm = _filter_get_choice(existing, "type") - os_, oe, om = _filter_get_choice(existing, "ownership_type") - price = existing.get("price", {}) - pmin = str(price.get("value", "")) if isinstance(price, dict) else "" - 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 - ) + 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: - a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price")) - plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1) + 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: - plo, phi = 0, 100 + price_range_min, price_range_max = 0, 100 fields = [ Component( @@ -727,16 +988,23 @@ def PurchaseFilterBar( children=[ _filter_field( "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( "Platform", SelectableFilter( "platform", - plat_opts, - ps, - pe, - pm, + platform_options, + platform_choice.selected, + platform_choice.excluded, + platform_choice.modifier, nullable=Purchase._meta.get_field("platform").null, ), ), @@ -744,10 +1012,10 @@ def PurchaseFilterBar( "Type", SelectableFilter( "type", - type_opts, - ts, - te, - tm, + type_options, + type_choice.selected, + type_choice.excluded, + type_choice.modifier, nullable=not Purchase._meta.get_field("type").has_default(), ), ), @@ -755,10 +1023,10 @@ def PurchaseFilterBar( "Ownership", SelectableFilter( "ownership_type", - own_opts, - os_, - oe, - om, + ownership_options, + ownership_choice.selected, + ownership_choice.excluded, + ownership_choice.modifier, nullable=not Purchase._meta.get_field( "ownership_type" ).has_default(), @@ -768,15 +1036,20 @@ def PurchaseFilterBar( ), Component( tag_name="div", - attributes=[("class", _FILTER_GRID_CLASS)], + attributes=[("class", "flex items-end gap-4 mb-4")], children=[ - _filter_number("Price Min", "filter-price-min", pmin, "0.00"), - _filter_number("Price Max", "filter-price-max", pmax, "100.00"), - _filter_checkbox("filter-refunded", "Refunded", rf), + _filter_checkbox("filter-refunded", "Refunded", is_refunded_value), ], ), - _filter_range_handles( - "price-range", "filter-price-min", "filter-price-max", plo, phi + 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", ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) diff --git a/common/input.css b/common/input.css index fe8c9da..ab07998 100644 --- a/common/input.css +++ b/common/input.css @@ -231,3 +231,49 @@ textarea:disabled { @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; +} + diff --git a/games/static/base.css b/games/static/base.css index 2816dc2..f2cb7aa 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1470,15 +1470,9 @@ .h-full { height: 100%; } - .max-h-40 { - max-height: calc(var(--spacing) * 40); - } .max-h-full { max-height: 100%; } - .min-h-\[28px\] { - min-height: 28px; - } .min-h-screen { min-height: 100vh; } @@ -1655,6 +1649,9 @@ .flex-shrink-0 { flex-shrink: 0; } + .shrink-0 { + flex-shrink: 0; + } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1713,9 +1710,6 @@ .list-disc { list-style-type: disc; } - .appearance-none { - appearance: none; - } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -2125,6 +2119,9 @@ .bg-neutral-primary-soft { background-color: var(--color-neutral-primary-soft); } + .bg-neutral-quaternary { + background-color: var(--color-neutral-quaternary); + } .bg-neutral-secondary-medium { background-color: var(--color-neutral-secondary-medium); } @@ -2331,9 +2328,6 @@ color: heading !important; } } - .pb-1 { - padding-bottom: calc(var(--spacing) * 1); - } .pb-16 { padding-bottom: calc(var(--spacing) * 16); } @@ -3074,12 +3068,6 @@ color: var(--color-blue-700); } } - .focus\:ring-0 { - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -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 { border-start-start-radius: var(--radius-lg); @@ -4358,6 +4301,171 @@ form input:disabled, select:disabled, textarea:disabled { 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 { 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; diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 44d190b..393aa5f 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -46,9 +46,6 @@ * Returns a plain object ready for JSON.stringify. */ function buildFilterJSON(form) { - // Read all SelectableFilter widgets first - readSelectableFilters(form); - var filter = {}; var yearMin = numberValue(form, "filter-year-min"); var yearMax = numberValue(form, "filter-year-max"); @@ -132,14 +129,7 @@ } 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 !== "") { filter.year_released = criterion(yearMin, null, "GREATER_THAN"); } else if (yearMax !== "") { @@ -371,8 +361,6 @@ } }); } - injectSearchInputs(); - document.addEventListener("DOMContentLoaded", function () { injectSearchInputs(); loadPresets(); diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js index b148e7f..a44bbff 100644 --- a/games/static/js/range_slider.js +++ b/games/static/js/range_slider.js @@ -1,5 +1,12 @@ /** - * Dual-handle range slider — pure JS with draggable handles. + * Range slider — custom draggable handles (no native ). + * + * 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 () { "use strict"; @@ -10,63 +17,109 @@ if (slider._rsInit) return; 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 maxHandle = slider.querySelector(".range-handle-max"); - var track = slider.querySelector(".range-track-fill"); if (!minHandle || !maxHandle) return; - var minTarget = document.getElementById(minHandle.getAttribute("data-target")); - var maxTarget = document.getElementById(maxHandle.getAttribute("data-target")); - var dMin = parseInt(slider.getAttribute("data-min"), 10); - var dMax = parseInt(slider.getAttribute("data-max"), 10); + var minTarget = document.getElementById( + minHandle.getAttribute("data-target") + ); + 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; - function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; } - function percentToValue(p) { - var raw = dMin + (p / 100) * (dMax - dMin); + // ── Helpers ── + + 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; } - 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 setTargetVal(el, v) { if (el) el.value = v; } + function getTargetValue(target) { + return parseInt(target ? target.value : 0, 10) || dataMin; + } + function setTargetValue(target, value) { + if (target) target.value = value; + } - function update() { - var minV = getTargetVal(minTarget); - var maxV = getTargetVal(maxTarget); - minV = clamp(minV, dMin, dMax); - maxV = clamp(maxV, dMin, dMax); - if (minV > maxV) minV = maxV; - if (maxV < minV) maxV = minV; - setTargetVal(minTarget, minV); - setTargetVal(maxTarget, maxV); - var minP = valueToPercent(minV); - var maxP = valueToPercent(maxV); - minHandle.style.left = minP + "%"; - maxHandle.style.left = maxP + "%"; - if (track) { - track.style.left = minP + "%"; - track.style.width = (maxP - minP) + "%"; + // ── Track fill positioning ── + + function updateTrackFill() { + if (!trackFill) return; + var minValue = getTargetValue(minTarget); + var maxValue = getTargetValue(maxTarget); + if (mode === "point") { + trackFill.style.left = "0%"; + trackFill.style.width = valueToPercent(maxValue) + "%"; + } else { + var leftPct = valueToPercent(minValue); + var widthPct = valueToPercent(maxValue) - leftPct; + trackFill.style.left = leftPct + "%"; + trackFill.style.width = widthPct + "%"; } } + function updateHandles() { + minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%"; + maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%"; + updateTrackFill(); + } + + // ── Dragging ── + function makeDraggable(handle, isMin) { handle.addEventListener("mousedown", function (e) { e.preventDefault(); var rect = slider.getBoundingClientRect(); + function onMove(ev) { var pct = ((ev.clientX - rect.left) / rect.width) * 100; - var v = percentToValue(clamp(pct, 0, 100)); - if (isMin) { - minTarget.value = clamp(v, dMin, getTargetVal(maxTarget)); + var value = percentToValue(clamp(pct, 0, 100)); + + 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 { - maxTarget.value = clamp(v, getTargetVal(minTarget), dMax); + setTargetValue( + maxTarget, + clamp(value, getTargetValue(minTarget), dataMax) + ); + if (maxTarget) + maxTarget.dispatchEvent( + new Event("input", { bubbles: true }) + ); } - update(); - // Trigger input event on the target so any listeners fire - var tgt = isMin ? minTarget : maxTarget; - if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true })); + updateHandles(); } + function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); @@ -80,17 +133,64 @@ makeDraggable(minHandle, true); makeDraggable(maxHandle, false); - // Sync from inputs to slider - function fromInputs() { update(); } - if (minTarget) minTarget.addEventListener("input", fromInputs); - if (maxTarget) maxTarget.addEventListener("input", fromInputs); + // ── Sync from number inputs back to handles ── - 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("htmx:afterSwap", initAll); - // Expose for manual re-init (filter bar toggle) window.initRangeSliders = initAll; })(); diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 8578827..22bde72 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -1,10 +1,11 @@ """Characterization tests locking the rendered output of the three filter bars. The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the -target of an upcoming dedup + module split. These tests pin the structural -contract — form/input ids, the hidden ``filter`` field, preset wiring, the -filter_json round-trip, and no double-escaping — so that refactor stays -behaviour-preserving. The renderers were previously untested. +target of a dedup + module split + RangeSlider component extraction. These tests +pin the structural contract — form/input ids, the hidden ``filter`` field, +preset wiring, the filter_json round-trip, no double-escaping, and the +Flowbite-styled native range slider unification — so that refactor stays +behaviour-preserving. """ import json @@ -41,6 +42,24 @@ class FilterBarRenderingTest(TestCase): self.assertIn(save_url, html) # preset save URL wired in self.assertNoEscapedTags(html) + def _assert_range_slider(self, html): + """Every filter bar must use the RangeSlider component with custom + draggable
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( + ' found — should use custom div handles", + ) + def test_game_filter_bar(self): html = str( FilterBar( @@ -50,6 +69,7 @@ class FilterBarRenderingTest(TestCase): ) ) self._assert_shell(html, "/presets/games/list", "/presets/games/save") + self._assert_range_slider(html) def test_session_filter_bar(self): html = str( @@ -60,6 +80,7 @@ class FilterBarRenderingTest(TestCase): ) ) self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save") + self._assert_range_slider(html) def test_purchase_filter_bar(self): html = str( @@ -70,6 +91,7 @@ class FilterBarRenderingTest(TestCase): ) ) self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save") + self._assert_range_slider(html) def test_game_filter_bar_roundtrips_selected_status(self): """A status in filter_json renders as a selected tag in the widget.""" diff --git a/tests/test_filter_helpers.py b/tests/test_filter_helpers.py new file mode 100644 index 0000000..96381d1 --- /dev/null +++ b/tests/test_filter_helpers.py @@ -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"))