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 ───────────────────────────────────────────────────────────── 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": """Render a collapsible filter bar with SelectableFilter widgets.""" 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: dict = {} if filter_json: try: import json existing = json.loads(filter_json) except (json.JSONDecodeError, TypeError): pass def _get_choice(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 "" status_sel, status_excl, status_mod = _get_choice("status") plat_sel, plat_excl, plat_mod = _get_choice("platform") plat_opts_str: list[tuple[str, str]] = [(str(k), v) for k, v in platform_options] def _mins_to_hrs(val): 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}" 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 = ( _mins_to_hrs(playtime.get("value", "")) if isinstance(playtime, dict) else "" ) playtime_max = ( _mins_to_hrs(playtime.get("value2", "")) if isinstance(playtime, dict) else "" ) # DB-backed ranges for sliders 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 ) form_id = "filter-bar-form" filter_input_id = "filter-json-input" def _number(label, name, value="", placeholder=""): return Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ), ], children=[label], ), Component( tag_name="input", attributes=[ ("type", "number"), ("name", escape(name)), ("id", escape(name)), ("value", escape(value)), ("placeholder", escape(placeholder)), ( "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", ), ], ), ], ) def _range(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"): 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'' ), ], ) return Component( tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[ 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", ], ), 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", form_id), ("onsubmit", "return applyFilterBar(event)"), ], children=[ Component( tag_name="input", attributes=[ ("type", "hidden"), ("id", filter_input_id), ("name", "filter"), ("value", escape(filter_json)), ], ), Component( tag_name="div", attributes=[ ( "class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", ), ], children=[ Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ), ], children=["Status"], ), SelectableFilter( "status", status_options, status_sel, status_excl, status_mod, nullable=not Game._meta.get_field( "status" ).has_default(), ), ], ), Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ), ], children=["Platform"], ), SelectableFilter( "platform", plat_opts_str, plat_sel, plat_excl, plat_mod, nullable=Game._meta.get_field( "platform" ).null, ), ], ), _number( "Year Min", "filter-year-min", year_min, "e.g. 2020", ), _number( "Year Max", "filter-year-max", year_max, "e.g. 2024", ), ], ), _range( "year-range", "filter-year-min", "filter-year-max", year_min, year_max, yr_data_min, yr_data_max, ), Component( tag_name="div", attributes=[ ( "class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", ), ], children=[ _number( "Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1", ), _number( "Playtime Max (hrs)", "filter-playtime-max", playtime_max, "e.g. 100", ), Component( tag_name="div", attributes=[("class", "flex items-end pb-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "flex items-center gap-2 text-sm text-heading", ), ], children=[ Component( tag_name="input", attributes=[ ("type", "checkbox"), ("name", "filter-mastered"), ("value", "1"), *( [("checked", "true")] if mastered_val else [] ), ( "class", "rounded border-default-medium " "bg-neutral-secondary-medium text-brand focus:ring-brand", ), ], ), "Mastered", ], ), ], ), ], ), _range( "playtime-range", "filter-playtime-min", "filter-playtime-max", playtime_min or "0", playtime_max or str(pt_data_max), 0, pt_data_max, ), 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('{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('{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..."], ), ], ), ], ), ], ), ], ), ], ) # ── 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=""): 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 = {} if filter_json: try: import json existing = json.loads(filter_json) except Exception: pass def _gc(f): raw = existing.get(f, {}) if not isinstance(raw, dict): return [], [], "" v = raw.get("value", []) e = raw.get("excludes", []) m = raw.get("modifier", "") if isinstance(v, str): v = [v] if isinstance(e, str): e = [e] return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or "" gs, ge, gm = _gc("game") ds, de, dm = _gc("device") def _mh(v): if v is None or v == "" or v == 0: return "" try: m = int(v) except: return "" if m == 0: return "" h = m / 60 return str(int(h)) if h == int(h) else f"{h:.1f}" dur = existing.get("duration_minutes", {}) dmin = _mh(dur.get("value", "")) if isinstance(dur, dict) else "" dmax = _mh(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 fd, hd = "filter-bar-form", "filter-json-input" def _n(l, n, v="", p=""): return Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=[l], ), Component( tag_name="input", attributes=[ ("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ( "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", ), ], ), ], ) def _r(cls, mi, mx, iv, xv, lo, hi, s="1"): 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(s)), ], children=[ mark_safe( '
' + f'
' ), ], ) return Component( tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[ 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", ], ), 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", fd), ("onsubmit", "return applyFilterBar(event)"), ], children=[ Component( tag_name="input", attributes=[ ("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json)), ], ), Component( tag_name="div", attributes=[ ( "class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", ) ], children=[ Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["Game"], ), SelectableFilter( "game", game_opts, gs, ge, gm, nullable=not Game._meta.get_field( "name" ).has_default(), ), ], ), Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["Device"], ), SelectableFilter( "device", dev_opts, ds, de, dm, nullable=Session._meta.get_field( "device" ).null, ), ], ), _n( "Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5", ), _n( "Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10", ), ], ), _r( "dur-range", "filter-playtime-min", "filter-playtime-max", dmin or "0", dmax or str(ddm), 0, ddm, ), Component( tag_name="div", attributes=[("class", "flex gap-4 mb-4")], children=[ Component( tag_name="label", attributes=[ ( "class", "flex items-center gap-2 text-sm text-heading", ) ], children=[ Component( tag_name="input", attributes=[ ("type", "checkbox"), ("name", "filter-emulated"), ("value", "1"), *( [("checked", "true")] if em else [] ), ( "class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", ), ], ), "Emulated", ], ), Component( tag_name="label", attributes=[ ( "class", "flex items-center gap-2 text-sm text-heading", ) ], children=[ Component( tag_name="input", attributes=[ ("type", "checkbox"), ("name", "filter-active"), ("value", "1"), *( [("checked", "true")] if ac else [] ), ( "class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", ), ], ), "Active", ], ), ], ), 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('{fd}', '{hd}')", ), ( "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('{fd}', '{hd}', '{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 PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""): 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 = {} if filter_json: try: import json existing = json.loads(filter_json) except Exception: pass def _gc(f): raw = existing.get(f, {}) if not isinstance(raw, dict): return [], [], "" v = raw.get("value", []) e = raw.get("excludes", []) m = raw.get("modifier", "") if isinstance(v, str): v = [v] if isinstance(e, str): e = [e] return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or "" gs, ge, gm = _gc("games") ps, pe, pm = _gc("platform") ts, te, tm = _gc("type") os, oe, om = _gc("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 fd, hd = "filter-bar-form", "filter-json-input" def _n(l, n, v="", p=""): return Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=[l], ), Component( tag_name="input", attributes=[ ("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ( "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", ), ], ), ], ) def _r(cls, mi, mx, iv, xv, lo, hi, s="1"): 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(s)), ], children=[ mark_safe( '
' + f'
' ), ], ) return Component( tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[ 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", ], ), 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", fd), ("onsubmit", "return applyFilterBar(event)"), ], children=[ Component( tag_name="input", attributes=[ ("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json)), ], ), Component( tag_name="div", attributes=[ ( "class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", ) ], children=[ Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["Game"], ), SelectableFilter( "games", game_opts, gs, ge, gm, nullable=False, ), ], ), Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["Platform"], ), SelectableFilter( "platform", plat_opts, ps, pe, pm, nullable=Purchase._meta.get_field( "platform" ).null, ), ], ), Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["Type"], ), SelectableFilter( "type", type_opts, ts, te, tm, nullable=not Purchase._meta.get_field( "type" ).has_default(), ), ], ), Component( tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ Component( tag_name="label", attributes=[ ( "class", "text-xs font-medium text-body uppercase tracking-wide", ) ], children=["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", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", ) ], children=[ _n("Price Min", "filter-price-min", pmin, "0.00"), _n("Price Max", "filter-price-max", pmax, "100.00"), Component( tag_name="label", attributes=[ ( "class", "flex items-center gap-2 text-sm text-heading", ) ], children=[ Component( tag_name="input", attributes=[ ("type", "checkbox"), ("name", "filter-refunded"), ("value", "1"), *( [("checked", "true")] if rf else [] ), ( "class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", ), ], ), "Refunded", ], ), ], ), _r( "price-range", "filter-price-min", "filter-price-max", pmin or str(plo), pmax or str(phi), plo, phi, ), 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('{fd}', '{hd}')", ), ( "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('{fd}', '{hd}', '{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..."], ) ], ), ], ), ], ), ], ), ], )