diff --git a/_add_search.py b/_add_search.py
new file mode 100644
index 0000000..d965066
--- /dev/null
+++ b/_add_search.py
@@ -0,0 +1,48 @@
+with open("common/components.py", "r") as f:
+ content = f.read()
+
+# Count FilterBar functions to know which replacement targets which
+n = content.count('("name", "filter"), ("value", escape(filter_json))')
+print(f"Found {n} hidden filter inputs")
+
+# Simple: after each hidden filter input, insert a search input
+search_html = ''' Component(tag_name="input", attributes=[
+ ("type", "text"), ("name", "filter-search"),
+ ("value", escape(search_val)),
+ ("placeholder", "Search\u2026"),
+ ("class", "block w-full rounded-base border border-default-medium "
+ "bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 "
+ "focus:ring-brand focus:border-brand"),
+ ]),
+'''
+
+old = ''' Component(tag_name="input", attributes=[
+ ("type", "hidden"), ("id", filter_input_id),
+ ("name", "filter"), ("value", escape(filter_json)),
+ ]),
+ Component(tag_name="div", attributes=['''
+
+# Only replace occurrences in FilterBar functions (after 'def FilterBar' or 'def SessionFilterBar' or 'def PurchaseFilterBar')
+# Find each occurrence and replace
+import re
+# Strategy: split by the old pattern, insert search_html between first two parts of each split
+parts = content.split(old)
+print(f"Split into {len(parts)} parts")
+
+new_content = parts[0]
+for i in range(1, len(parts)):
+ # Check if this occurrence is inside a FilterBar function (not inside SelectableFilter)
+ # Simple heuristic: the context before should contain 'FilterBar'
+ chunk_before = parts[i-1][-500:] if len(parts[i-1]) > 500 else parts[i-1]
+ is_filterbar = 'FilterBar' in chunk_before or 'filter_bar' in chunk_before.lower()
+ if is_filterbar:
+ new_content += old + search_html + parts[i]
+ else:
+ new_content += old + parts[i]
+
+with open("common/components.py", "w") as f:
+ f.write(new_content)
+
+import ast
+ast.parse(new_content)
+print("OK")
diff --git a/common/components.py b/common/components.py
index 0f98499..26fb8a8 100644
--- a/common/components.py
+++ b/common/components.py
@@ -7,6 +7,7 @@ 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.db import models
from django.utils.safestring import SafeText, mark_safe
from common.icons import get_icon
@@ -951,7 +952,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
{"".join(f"
{game.name}" for game in purchase.games.all())}
"""
- icon = purchase.platform.icon if game_count == 1 else "unspecified"
+ icon = (purchase.platform.icon if purchase.platform else "unspecified") if game_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
@@ -1171,3 +1172,519 @@ def _dropdown_button_html(button_content: str, list_items: str) -> str:
""
""
)
+
+
+# ── 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"),
+ ], 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 Game, Device, 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..."])]),
+ ]),
+ ]),
+ ]),
+ ])
diff --git a/common/criteria.py b/common/criteria.py
new file mode 100644
index 0000000..50be0f8
--- /dev/null
+++ b/common/criteria.py
@@ -0,0 +1,451 @@
+"""
+Typed criterion inputs for building structured filters.
+
+Inspired by Stash's filter architecture: every filterable field uses a typed
+criterion with a value and a CriterionModifier. This separates *what* you're
+filtering from *how* you're comparing, and makes filter serialization trivial.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field, fields as dc_fields
+from enum import Enum
+from typing import Any, Self, TypeVar
+
+from django.db.models import Q
+
+# ── Modifier ──────────────────────────────────────────────────────────────
+
+
+class Modifier(str, Enum):
+ """Comparison operators shared across all criterion types."""
+
+ EQUALS = "EQUALS"
+ NOT_EQUALS = "NOT_EQUALS"
+ GREATER_THAN = "GREATER_THAN"
+ LESS_THAN = "LESS_THAN"
+ BETWEEN = "BETWEEN"
+ NOT_BETWEEN = "NOT_BETWEEN"
+ INCLUDES = "INCLUDES"
+ EXCLUDES = "EXCLUDES"
+ INCLUDES_ALL = "INCLUDES_ALL"
+ IS_NULL = "IS_NULL"
+ NOT_NULL = "NOT_NULL"
+ MATCHES_REGEX = "MATCHES_REGEX"
+ NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
+
+ @classmethod
+ def for_strings(cls) -> list[Self]:
+ return [
+ cls.EQUALS, cls.NOT_EQUALS,
+ cls.INCLUDES, cls.EXCLUDES,
+ cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
+ cls.IS_NULL, cls.NOT_NULL,
+ ]
+
+ @classmethod
+ def for_numbers(cls) -> list[Self]:
+ return [
+ cls.EQUALS, cls.NOT_EQUALS,
+ cls.GREATER_THAN, cls.LESS_THAN,
+ cls.BETWEEN, cls.NOT_BETWEEN,
+ cls.IS_NULL, cls.NOT_NULL,
+ ]
+
+ @classmethod
+ def for_dates(cls) -> list[Self]:
+ return cls.for_numbers()
+
+ @classmethod
+ def for_multi(cls) -> list[Self]:
+ return [
+ cls.INCLUDES, cls.EXCLUDES,
+ cls.INCLUDES_ALL,
+ cls.IS_NULL, cls.NOT_NULL,
+ ]
+
+
+# ── Base criterion ─────────────────────────────────────────────────────────
+
+T = TypeVar("T")
+
+
+@dataclass
+class _Criterion:
+ """Base for all typed criteria."""
+
+ value: Any = None
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ raise NotImplementedError
+
+ @classmethod
+ def from_json(cls, data: dict | None) -> Self | None:
+ if data is None or not isinstance(data, dict):
+ return None
+ kwargs: dict[str, Any] = {}
+ for f in dc_fields(cls):
+ if f.name in data:
+ val = data[f.name]
+ # Coerce string modifier to Modifier enum
+ if f.name == "modifier" and isinstance(val, str):
+ val = Modifier(val)
+ kwargs[f.name] = val
+ return cls(**kwargs)
+
+ def to_json(self) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for f in dc_fields(self):
+ v = getattr(self, f.name)
+ if v is not None and v != f.default:
+ result[f.name] = v
+ return result
+
+
+# ── Concrete criteria ──────────────────────────────────────────────────────
+
+
+@dataclass
+class StringCriterion(_Criterion):
+ value: str = ""
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.EQUALS:
+ return Q(**{field_name: self.value})
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{field_name: self.value})
+ if m == Modifier.INCLUDES:
+ return Q(**{f"{field_name}__icontains": self.value})
+ if m == Modifier.EXCLUDES:
+ return ~Q(**{f"{field_name}__icontains": self.value})
+ if m == Modifier.MATCHES_REGEX:
+ return Q(**{f"{field_name}__regex": self.value})
+ if m == Modifier.NOT_MATCHES_REGEX:
+ return ~Q(**{f"{field_name}__regex": self.value})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for string field")
+
+
+@dataclass
+class IntCriterion(_Criterion):
+ value: int = 0
+ value2: int | None = None
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.EQUALS:
+ return Q(**{field_name: self.value})
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{field_name: self.value})
+ if m == Modifier.GREATER_THAN:
+ return Q(**{f"{field_name}__gt": self.value})
+ if m == Modifier.LESS_THAN:
+ return Q(**{f"{field_name}__lt": self.value})
+ if m == Modifier.BETWEEN:
+ if self.value2 is None:
+ raise ValueError("BETWEEN requires value2")
+ return Q(**{f"{field_name}__gte": min(self.value, self.value2),
+ f"{field_name}__lte": max(self.value, self.value2)})
+ if m == Modifier.NOT_BETWEEN:
+ if self.value2 is None:
+ raise ValueError("NOT_BETWEEN requires value2")
+ lo, hi = min(self.value, self.value2), max(self.value, self.value2)
+ return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for int field")
+
+
+@dataclass
+class FloatCriterion(_Criterion):
+ value: float = 0.0
+ value2: float | None = None
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.EQUALS:
+ return Q(**{field_name: self.value})
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{field_name: self.value})
+ if m == Modifier.GREATER_THAN:
+ return Q(**{f"{field_name}__gt": self.value})
+ if m == Modifier.LESS_THAN:
+ return Q(**{f"{field_name}__lt": self.value})
+ if m == Modifier.BETWEEN:
+ if self.value2 is None:
+ raise ValueError("BETWEEN requires value2")
+ return Q(**{f"{field_name}__gte": min(self.value, self.value2),
+ f"{field_name}__lte": max(self.value, self.value2)})
+ if m == Modifier.NOT_BETWEEN:
+ if self.value2 is None:
+ raise ValueError("NOT_BETWEEN requires value2")
+ lo, hi = min(self.value, self.value2), max(self.value, self.value2)
+ return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for float field")
+
+
+@dataclass
+class DateCriterion(_Criterion):
+ value: str = ""
+ value2: str | None = None
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.EQUALS:
+ return Q(**{field_name: self.value})
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{field_name: self.value})
+ if m == Modifier.GREATER_THAN:
+ return Q(**{f"{field_name}__gt": self.value})
+ if m == Modifier.LESS_THAN:
+ return Q(**{f"{field_name}__lt": self.value})
+ if m == Modifier.BETWEEN:
+ if self.value2 is None:
+ raise ValueError("BETWEEN requires value2")
+ return Q(**{f"{field_name}__gte": self.value,
+ f"{field_name}__lte": self.value2})
+ if m == Modifier.NOT_BETWEEN:
+ if self.value2 is None:
+ raise ValueError("NOT_BETWEEN requires value2")
+ return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for date field")
+
+
+@dataclass
+class BoolCriterion(_Criterion):
+ value: bool = False
+ # Bool only makes sense with EQUALS
+ modifier: Modifier = Modifier.EQUALS
+
+ def to_q(self, field_name: str) -> Q:
+ if self.modifier == Modifier.EQUALS:
+ return Q(**{field_name: self.value})
+ if self.modifier == Modifier.NOT_EQUALS:
+ return ~Q(**{field_name: self.value})
+ raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
+
+
+@dataclass
+class MultiCriterion(_Criterion):
+ """Filter on a many-to-many or ForeignKey relationship by ID list."""
+ value: list[int] = field(default_factory=list)
+ excludes: list[int] = field(default_factory=list)
+ modifier: Modifier = Modifier.INCLUDES
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.INCLUDES:
+ q = Q(**{f"{field_name}__in": self.value})
+ if self.excludes:
+ q &= ~Q(**{f"{field_name}__in": self.excludes})
+ return q
+ if m == Modifier.EXCLUDES:
+ return ~Q(**{f"{field_name}__in": self.value})
+ if m == Modifier.INCLUDES_ALL:
+ q = Q()
+ for v in self.value:
+ q &= Q(**{field_name: v})
+ return q
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for multi field")
+
+
+@dataclass
+class ChoiceCriterion(_Criterion):
+ """Filter on a choice/enum field with multi-select include/exclude.
+
+ Used by SelectableFilter widgets for status, ownership_type, etc.
+ Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
+ """
+
+ value: list[str] = field(default_factory=list)
+ excludes: list[str] = field(default_factory=list)
+ modifier: Modifier = Modifier.INCLUDES
+
+ def to_q(self, field_name: str) -> Q:
+ m = self.modifier
+ if m == Modifier.INCLUDES:
+ q = Q()
+ if self.value:
+ q &= Q(**{f"{field_name}__in": self.value})
+ if self.excludes:
+ q &= ~Q(**{f"{field_name}__in": self.excludes})
+ return q
+ if m == Modifier.EXCLUDES:
+ q = Q()
+ if self.value:
+ q &= ~Q(**{f"{field_name}__in": self.value})
+ if self.excludes:
+ q &= Q(**{f"{field_name}__in": self.excludes})
+ return q
+ if m == Modifier.EQUALS:
+ q = Q()
+ if self.value:
+ q &= Q(**{f"{field_name}__in": self.value})
+ if self.excludes:
+ q &= ~Q(**{f"{field_name}__in": self.excludes})
+ return q
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{f"{field_name}__in": self.value})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field_name}__isnull": True})
+ if m == Modifier.NOT_NULL:
+ return Q(**{f"{field_name}__isnull": False})
+ raise ValueError(f"Unsupported modifier {m} for choice field")
+
+
+# ── OperatorFilter base ────────────────────────────────────────────────────
+
+F = TypeVar("F", bound="OperatorFilter")
+
+
+@dataclass
+class OperatorFilter:
+ """Mixin providing AND/OR/NOT composition for entity filter types.
+
+ Subclasses should declare nullable references to themselves::
+
+ @dataclass
+ class GameFilter(OperatorFilter):
+ AND: "GameFilter | None" = None
+ OR: "GameFilter | None" = None
+ NOT: "GameFilter | None" = None
+ name: StringCriterion | None = None
+ ...
+ """
+
+ def sub_filter(self) -> OperatorFilter | None:
+ """Return the first non-None of AND / OR / NOT."""
+ for attr in ("AND", "OR", "NOT"):
+ if hasattr(self, attr):
+ v = getattr(self, attr)
+ if v is not None:
+ return v
+ return None
+
+ def _criterion_fields(self) -> list[str]:
+ """Return field names that hold a _Criterion instance."""
+ names: list[str] = []
+ for f in dc_fields(self):
+ if f.name in ("AND", "OR", "NOT"):
+ continue
+ v = getattr(self, f.name)
+ if isinstance(v, _Criterion):
+ names.append(f.name)
+ return names
+
+ def to_q(self) -> Q:
+ """Build a Django Q object from this filter and its sub-filters."""
+ q = Q()
+ for field_name in self._criterion_fields():
+ c = getattr(self, field_name)
+ if c is not None:
+ q &= c.to_q(field_name)
+ sub = self.sub_filter()
+ if sub is not None:
+ if getattr(self, "AND", None) is not None:
+ q &= sub.to_q()
+ elif getattr(self, "OR", None) is not None:
+ q |= sub.to_q()
+ elif getattr(self, "NOT", None) is not None:
+ q &= ~sub.to_q()
+ return q
+
+ @classmethod
+ def from_json(cls, data: dict[str, Any] | None) -> Self | None:
+ if data is None or not isinstance(data, dict):
+ return None
+ # Resolve criterion class names to actual types
+ criterion_types: dict[str, type[_Criterion]] = {
+ "StringCriterion": StringCriterion,
+ "IntCriterion": IntCriterion,
+ "FloatCriterion": FloatCriterion,
+ "DateCriterion": DateCriterion,
+ "BoolCriterion": BoolCriterion,
+ "MultiCriterion": MultiCriterion,
+ "ChoiceCriterion": ChoiceCriterion,
+ }
+ kwargs: dict[str, Any] = {}
+ for f in dc_fields(cls):
+ if f.name not in data:
+ continue
+ raw = data[f.name]
+ if raw is None:
+ kwargs[f.name] = None
+ continue
+ # Recurse into sub-filters (AND / OR / NOT)
+ if f.name in ("AND", "OR", "NOT"):
+ kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
+ continue
+ # Resolve criterion fields from string type annotation
+ f_type = f.type
+ if isinstance(f_type, str):
+ # e.g. "StringCriterion | None" → "StringCriterion"
+ f_type = f_type.split("|")[0].strip()
+ if isinstance(f_type, str) and f_type in criterion_types:
+ criterion_cls = criterion_types[f_type]
+ kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
+ elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
+ kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
+ return cls(**kwargs)
+
+ def to_json(self) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for f in dc_fields(self):
+ v = getattr(self, f.name)
+ if v is None:
+ continue
+ if f.name in ("AND", "OR", "NOT"):
+ result[f.name] = v.to_json()
+ elif isinstance(v, _Criterion):
+ j = v.to_json()
+ if j:
+ result[f.name] = j
+ return result
+
+
+# ── JSON helpers ───────────────────────────────────────────────────────────
+
+
+def filter_from_json(cls: type[F], json_str: str) -> F | None:
+ """Deserialize a filter from a JSON string.
+
+ Usage:
+ f = filter_from_json(GameFilter, request.GET.get("filter", ""))
+ games = Game.objects.filter(f.to_q())
+ """
+ if not json_str:
+ return None
+ try:
+ data = json.loads(json_str)
+ except json.JSONDecodeError:
+ return None
+ return cls.from_json(data)
+
+
+def filter_to_json(f: OperatorFilter) -> str:
+ """Serialize a filter to a JSON string for URL params or storage."""
+ return json.dumps(f.to_json())
diff --git a/games/filters.py b/games/filters.py
new file mode 100644
index 0000000..350ee23
--- /dev/null
+++ b/games/filters.py
@@ -0,0 +1,384 @@
+"""
+Entity-specific filter types for the timetracker app.
+
+Each filter class mirrors a Django model, with fields expressed as typed
+criteria from common.criteria. The to_q() method produces a Django Q object
+ready for queryset.filter().
+
+Inspired by Stash's filter architecture: each entity has an OperatorFilter
+with AND/OR/NOT composition and typed criterion fields.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from common.criteria import (
+ BoolCriterion,
+ ChoiceCriterion,
+ FloatCriterion,
+ IntCriterion,
+ Modifier,
+ MultiCriterion,
+ OperatorFilter,
+ StringCriterion,
+ filter_from_json,
+)
+
+# ── FindFilter (sort / pagination) ─────────────────────────────────────────
+
+
+@dataclass
+class FindFilter:
+ """Sorting and pagination, separate from filtering criteria (Stash-style)."""
+
+ q: str | None = None # free-text search
+ page: int = 1
+ per_page: int = 25
+ sort: str | None = None # e.g. "-created_at"
+ direction: str = "desc" # asc / desc
+
+
+# ── GameFilter ─────────────────────────────────────────────────────────────
+
+
+@dataclass
+class GameFilter(OperatorFilter):
+ """Filter for the Game model."""
+
+ AND: GameFilter | None = None
+ OR: GameFilter | None = None
+ NOT: GameFilter | None = None
+
+ name: StringCriterion | None = None
+ sort_name: StringCriterion | None = None
+ year_released: IntCriterion | None = None
+ original_year_released: IntCriterion | None = None
+ wikidata: StringCriterion | None = None
+ platform: ChoiceCriterion | None = None # selectable filter widget
+ status: ChoiceCriterion | None = None # selectable filter widget
+ mastered: BoolCriterion | None = None
+ playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
+ created_at: StringCriterion | None = None # date string
+ updated_at: StringCriterion | None = None # date string
+
+ # Free-text search (combines name + sort_name + platform name)
+ search: StringCriterion | None = None
+
+ def to_q(self) -> "Q": # type: ignore[no-any-unimported]
+ from django.db.models import Q
+
+ q = Q()
+
+ # ── individual criteria ──
+ if self.name is not None:
+ q &= self.name.to_q("name")
+ if self.sort_name is not None:
+ q &= self.sort_name.to_q("sort_name")
+ if self.year_released is not None:
+ q &= self.year_released.to_q("year_released")
+ if self.original_year_released is not None:
+ q &= self.original_year_released.to_q("original_year_released")
+ if self.wikidata is not None:
+ q &= self.wikidata.to_q("wikidata")
+ if self.platform is not None:
+ q &= self.platform.to_q("platform_id")
+ if self.status is not None:
+ q &= self.status.to_q("status")
+ if self.mastered is not None:
+ q &= self.mastered.to_q("mastered")
+ if self.playtime_minutes is not None:
+ q &= self._playtime_to_q(self.playtime_minutes)
+ if self.created_at is not None:
+ q &= self.created_at.to_q("created_at")
+ if self.updated_at is not None:
+ q &= self.updated_at.to_q("updated_at")
+
+ # ── free-text search (OR across multiple fields) ──
+ if self.search is not None and self.search.value:
+ search_q = (
+ Q(name__icontains=self.search.value)
+ | Q(sort_name__icontains=self.search.value)
+ | Q(platform__name__icontains=self.search.value)
+ )
+ if self.search.modifier == Modifier.EXCLUDES:
+ search_q = ~search_q
+ q &= search_q
+
+ # ── AND / OR / NOT sub-filters ──
+ sub = self.sub_filter()
+ if sub is not None:
+ if self.AND is not None:
+ q &= sub.to_q()
+ elif self.OR is not None:
+ q |= sub.to_q()
+ elif self.NOT is not None:
+ q &= ~sub.to_q()
+
+ return q
+
+ @staticmethod
+ def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
+ """Convert minutes-based criterion to a DurationField Q object.
+
+ Django stores DurationField as microseconds in SQLite, so we convert
+ minutes → timedelta(microseconds=X) and use the appropriate lookups.
+ """
+ from datetime import timedelta
+
+ from common.criteria import Modifier
+ from django.db.models import Q
+
+ m = c.modifier
+ field = "playtime"
+ td_val = timedelta(minutes=c.value)
+
+ if m == Modifier.EQUALS:
+ return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
+ if m == Modifier.NOT_EQUALS:
+ return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
+ if m == Modifier.GREATER_THAN:
+ return Q(**{f"{field}__gt": td_val})
+ if m == Modifier.LESS_THAN:
+ return Q(**{f"{field}__lt": td_val})
+ if m == Modifier.BETWEEN and c.value2 is not None:
+ lo = timedelta(minutes=min(c.value, c.value2))
+ hi = timedelta(minutes=max(c.value, c.value2))
+ return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
+ if m == Modifier.NOT_BETWEEN and c.value2 is not None:
+ lo = timedelta(minutes=min(c.value, c.value2))
+ hi = timedelta(minutes=max(c.value, c.value2))
+ return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
+ if m == Modifier.IS_NULL:
+ return Q(**{f"{field}": timedelta(0)})
+ if m == Modifier.NOT_NULL:
+ return ~Q(**{f"{field}": timedelta(0)})
+ return Q()
+
+
+# ── SessionFilter ──────────────────────────────────────────────────────────
+
+
+@dataclass
+class SessionFilter(OperatorFilter):
+ """Filter for the Session model."""
+
+ AND: SessionFilter | None = None
+ OR: SessionFilter | None = None
+ NOT: SessionFilter | None = None
+
+ game: MultiCriterion | None = None # filters on game_id
+ device: MultiCriterion | None = None # filters on device_id
+ emulated: BoolCriterion | None = None
+ note: StringCriterion | None = None
+ duration_minutes: IntCriterion | None = None # on duration_total
+ is_active: BoolCriterion | None = None # timestamp_end IS NULL
+ timestamp_start: StringCriterion | None = None # date string
+ timestamp_end: StringCriterion | None = None # date string
+ is_manual: BoolCriterion | None = None # duration_manual > 0
+ created_at: StringCriterion | None = None
+
+ # Free-text search
+ search: StringCriterion | None = None
+
+ # Cross-entity: sessions for games matching these criteria
+ game_filter: GameFilter | None = None
+
+ def to_q(self) -> "Q": # type: ignore[no-any-unimported]
+ from datetime import timedelta
+
+ from django.db.models import Q
+
+ q = Q()
+
+ if self.game is not None:
+ q &= self.game.to_q("game_id")
+ if self.device is not None:
+ q &= self.device.to_q("device_id")
+ if self.emulated is not None:
+ q &= self.emulated.to_q("emulated")
+ if self.note is not None:
+ q &= self.note.to_q("note")
+ if self.duration_minutes is not None:
+ c = self.duration_minutes
+ td_val = timedelta(minutes=c.value)
+ field = "duration_total"
+ m = c.modifier
+ if m == Modifier.EQUALS:
+ q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
+ elif m == Modifier.NOT_EQUALS:
+ q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
+ elif m == Modifier.GREATER_THAN:
+ q &= Q(**{f"{field}__gt": td_val})
+ elif m == Modifier.LESS_THAN:
+ q &= Q(**{f"{field}__lt": td_val})
+ elif m == Modifier.BETWEEN and c.value2 is not None:
+ lo = timedelta(minutes=min(c.value, c.value2))
+ hi = timedelta(minutes=max(c.value, c.value2))
+ q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
+ elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
+ lo = timedelta(minutes=min(c.value, c.value2))
+ hi = timedelta(minutes=max(c.value, c.value2))
+ q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
+ elif m == Modifier.IS_NULL:
+ q &= Q(**{f"{field}": timedelta(0)})
+ elif m == Modifier.NOT_NULL:
+ q &= ~Q(**{f"{field}": timedelta(0)})
+ if self.is_active is not None:
+ if self.is_active.value:
+ q &= Q(timestamp_end__isnull=True)
+ else:
+ q &= Q(timestamp_end__isnull=False)
+ if self.timestamp_start is not None:
+ q &= self.timestamp_start.to_q("timestamp_start")
+ if self.timestamp_end is not None:
+ q &= self.timestamp_end.to_q("timestamp_end")
+ if self.is_manual is not None:
+ if self.is_manual.value:
+ q &= ~Q(duration_manual=timedelta(0))
+ else:
+ q &= Q(duration_manual=timedelta(0))
+ if self.created_at is not None:
+ q &= self.created_at.to_q("created_at")
+
+ # Free-text search
+ if self.search is not None and self.search.value:
+ search_q = (
+ Q(game__name__icontains=self.search.value)
+ | Q(game__platform__name__icontains=self.search.value)
+ | Q(device__name__icontains=self.search.value)
+ | Q(device__type__icontains=self.search.value)
+ )
+ if self.search.modifier == Modifier.EXCLUDES:
+ search_q = ~search_q
+ q &= search_q
+
+ # Cross-entity filter: sessions for games matching GameFilter
+ if self.game_filter is not None:
+ from games.models import Game
+ game_q = self.game_filter.to_q()
+ matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
+ q &= Q(game_id__in=matching_ids)
+
+ # AND / OR / NOT
+ sub = self.sub_filter()
+ if sub is not None:
+ if self.AND is not None:
+ q &= sub.to_q()
+ elif self.OR is not None:
+ q |= sub.to_q()
+ elif self.NOT is not None:
+ q &= ~sub.to_q()
+
+ return q
+
+
+# ── PurchaseFilter ─────────────────────────────────────────────────────────
+
+
+@dataclass
+class PurchaseFilter(OperatorFilter):
+ """Filter for the Purchase model."""
+
+ AND: PurchaseFilter | None = None
+ OR: PurchaseFilter | None = None
+ NOT: PurchaseFilter | None = None
+
+ name: StringCriterion | None = None
+ platform: ChoiceCriterion | None = None # platform_id
+ games: ChoiceCriterion | None = None # games (M2M IDs)
+ date_purchased: StringCriterion | None = None # date string
+ date_refunded: StringCriterion | None = None # date string
+ is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
+ price: FloatCriterion | None = None # on price field
+ converted_price: FloatCriterion | None = None
+ price_currency: StringCriterion | None = None
+ num_purchases: IntCriterion | None = None
+ ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
+ type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
+ created_at: StringCriterion | None = None
+ updated_at: StringCriterion | None = None
+
+ # Free-text search
+ search: StringCriterion | None = None
+
+ # Cross-entity: purchases for games matching these criteria
+ game_filter: GameFilter | None = None
+
+ def to_q(self) -> "Q": # type: ignore[no-any-unimported]
+ from django.db.models import Q
+
+ q = Q()
+
+ if self.name is not None:
+ q &= self.name.to_q("name")
+ if self.platform is not None:
+ q &= self.platform.to_q("platform_id")
+ if self.games is not None:
+ q &= self.games.to_q("games")
+ if self.date_purchased is not None:
+ q &= self.date_purchased.to_q("date_purchased")
+ if self.date_refunded is not None:
+ q &= self.date_refunded.to_q("date_refunded")
+ if self.is_refunded is not None:
+ q &= Q(date_refunded__isnull=not self.is_refunded.value)
+ if self.price is not None:
+ q &= self.price.to_q("price")
+ if self.converted_price is not None:
+ q &= self.converted_price.to_q("converted_price")
+ if self.price_currency is not None:
+ q &= self.price_currency.to_q("price_currency")
+ if self.num_purchases is not None:
+ q &= self.num_purchases.to_q("num_purchases")
+ if self.ownership_type is not None:
+ q &= self.ownership_type.to_q("ownership_type")
+ if self.type is not None:
+ q &= self.type.to_q("type")
+ if self.created_at is not None:
+ q &= self.created_at.to_q("created_at")
+ if self.updated_at is not None:
+ q &= self.updated_at.to_q("updated_at")
+
+ # Free-text search
+ if self.search is not None and self.search.value:
+ search_q = (
+ Q(name__icontains=self.search.value)
+ | Q(games__name__icontains=self.search.value)
+ | Q(platform__name__icontains=self.search.value)
+ )
+ if self.search.modifier == Modifier.EXCLUDES:
+ search_q = ~search_q
+ q &= search_q
+
+ # Cross-entity filter
+ if self.game_filter is not None:
+ from games.models import Game
+ game_q = self.game_filter.to_q()
+ matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
+ q &= Q(games__id__in=matching_ids)
+
+ sub = self.sub_filter()
+ if sub is not None:
+ if self.AND is not None:
+ q &= sub.to_q()
+ elif self.OR is not None:
+ q |= sub.to_q()
+ elif self.NOT is not None:
+ q &= ~sub.to_q()
+
+ return q
+
+
+# ── Convenience helpers ────────────────────────────────────────────────────
+
+
+def parse_game_filter(json_str: str) -> GameFilter | None:
+ return filter_from_json(GameFilter, json_str)
+
+
+def parse_session_filter(json_str: str) -> SessionFilter | None:
+ return filter_from_json(SessionFilter, json_str)
+
+
+def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
+ return filter_from_json(PurchaseFilter, json_str)
diff --git a/games/forms.py b/games/forms.py
index 0f182b0..799527d 100644
--- a/games/forms.py
+++ b/games/forms.py
@@ -43,7 +43,7 @@ class SessionForm(forms.ModelForm):
),
label="Manual duration",
)
- device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
+ device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
mark_as_played = forms.BooleanField(
required=False,
@@ -104,6 +104,7 @@ class PurchaseForm(forms.ModelForm):
"hx-swap": "outerHTML",
}
)
+ self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
diff --git a/games/migrations/0017_add_filter_preset.py b/games/migrations/0017_add_filter_preset.py
new file mode 100644
index 0000000..e7064e8
--- /dev/null
+++ b/games/migrations/0017_add_filter_preset.py
@@ -0,0 +1,29 @@
+# Generated by Django 6.0.1 on 2026-06-06 07:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('games', '0016_add_needs_price_update'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FilterPreset',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255)),
+ ('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
+ ('find_filter', models.JSONField(blank=True, default=dict)),
+ ('object_filter', models.JSONField(blank=True, default=dict)),
+ ('ui_options', models.JSONField(blank=True, default=dict)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ ]
diff --git a/games/models.py b/games/models.py
index 7839525..c4ecf5c 100644
--- a/games/models.py
+++ b/games/models.py
@@ -478,3 +478,33 @@ class GameStatusChange(models.Model):
class Meta:
ordering = ["-timestamp"]
+
+
+class FilterPreset(models.Model):
+ """Saved filter configuration, following Stash's SavedFilter pattern.
+
+ Separates find_filter (sort/pagination), object_filter (criteria JSON),
+ and ui_options (presentation state) so they can evolve independently.
+ """
+
+ class Meta:
+ ordering = ["name"]
+
+ MODE_CHOICES = [
+ ("games", "Games"),
+ ("sessions", "Sessions"),
+ ("purchases", "Purchases"),
+ ("playevents", "Play Events"),
+ ]
+
+ name = models.CharField(max_length=255)
+ mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
+ find_filter = models.JSONField(default=dict, blank=True)
+ object_filter = models.JSONField(default=dict, blank=True)
+ ui_options = models.JSONField(default=dict, blank=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"{self.name} ({self.get_mode_display()})"
diff --git a/games/static/base.css b/games/static/base.css
index 69c934e..2816dc2 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -826,6 +826,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
+ .top-1\/2 {
+ top: calc(1 / 2 * 100%);
+ }
.top-3 {
top: calc(var(--spacing) * 3);
}
@@ -1273,6 +1276,9 @@
margin-left: -10px !important;
}
}
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
.ml-auto {
margin-left: auto;
}
@@ -1431,6 +1437,9 @@
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
+ .h-2 {
+ height: calc(var(--spacing) * 2);
+ }
.h-2\.5 {
height: calc(var(--spacing) * 2.5);
}
@@ -1461,9 +1470,15 @@
.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;
}
@@ -1656,6 +1671,10 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
+ .-translate-y-1\/2 {
+ --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ }
.-translate-y-full {
--tw-translate-y: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1694,6 +1713,12 @@
.list-disc {
list-style-type: disc;
}
+ .appearance-none {
+ appearance: none;
+ }
+ .grid-cols-1 {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1826,6 +1851,9 @@
.rounded-base {
border-radius: var(--radius-base);
}
+ .rounded-full {
+ border-radius: calc(infinity * 1px);
+ }
.rounded-lg {
border-radius: var(--radius-lg);
}
@@ -1888,6 +1916,10 @@
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
+ .border-2 {
+ border-style: var(--tw-border-style);
+ border-width: 2px;
+ }
.border-e {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px;
@@ -1984,9 +2016,15 @@
.border-red-200 {
border-color: var(--color-red-200);
}
+ .border-red-500 {
+ border-color: var(--color-red-500);
+ }
.border-transparent {
border-color: transparent;
}
+ .border-white {
+ border-color: var(--color-white);
+ }
.apexcharts-active {
.apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group {
padding: 0 !important;
@@ -2090,6 +2128,12 @@
.bg-neutral-secondary-medium {
background-color: var(--color-neutral-secondary-medium);
}
+ .bg-neutral-secondary-medium\/50 {
+ background-color: color-mix(in srgb, oklch(98.5% 0.002 247.839) 50%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-neutral-secondary-medium) 50%, transparent);
+ }
+ }
.bg-neutral-tertiary-medium {
background-color: var(--color-neutral-tertiary-medium);
}
@@ -2287,6 +2331,9 @@
color: heading !important;
}
}
+ .pb-1 {
+ padding-bottom: calc(var(--spacing) * 1);
+ }
.pb-16 {
padding-bottom: calc(var(--spacing) * 16);
}
@@ -2446,6 +2493,10 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
+ .tracking-wide {
+ --tw-tracking: var(--tracking-wide);
+ letter-spacing: var(--tracking-wide);
+ }
.text-balance {
text-wrap: balance;
}
@@ -2500,6 +2551,9 @@
.text-body {
color: var(--color-body);
}
+ .text-brand {
+ color: var(--color-brand);
+ }
.text-fg-brand {
color: var(--color-fg-brand);
}
@@ -2569,6 +2623,9 @@
.uppercase {
text-transform: uppercase;
}
+ .italic {
+ font-style: italic;
+ }
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2663,6 +2720,10 @@
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
+ .select-none {
+ -webkit-user-select: none;
+ user-select: none;
+ }
.\[program\:caddy\] {
program: caddy;
}
@@ -2792,6 +2853,16 @@
background-color: var(--color-gray-50);
}
}
+ .hover\:scale-110 {
+ &:hover {
+ @media (hover: hover) {
+ --tw-scale-x: 110%;
+ --tw-scale-y: 110%;
+ --tw-scale-z: 110%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ }
+ }
+ }
.hover\:cursor-pointer {
&:hover {
@media (hover: hover) {
@@ -2862,6 +2933,13 @@
}
}
}
+ .hover\:bg-neutral-secondary-medium {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-neutral-secondary-medium);
+ }
+ }
+ }
.hover\:bg-neutral-tertiary-medium {
&:hover {
@media (hover: hover) {
@@ -2967,6 +3045,13 @@
}
}
}
+ .hover\:text-red-700 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-red-700);
+ }
+ }
+ }
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -2989,6 +3074,12 @@
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);
@@ -3082,9 +3173,9 @@
max-width: var(--container-xl);
}
}
- .sm\:rounded-lg {
+ .sm\:grid-cols-2 {
@media (width >= 40rem) {
- border-radius: var(--radius-lg);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:rounded-t-lg {
@@ -3232,6 +3323,11 @@
max-width: var(--container-3xl);
}
}
+ .lg\:grid-cols-4 {
+ @media (width >= 64rem) {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ }
.xl\:max-w-\(--breakpoint-xl\) {
@media (width >= 80rem) {
max-width: var(--breakpoint-xl);
@@ -3799,6 +3895,51 @@
}
}
}
+ .\[\&\:\:-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);
@@ -5032,6 +5173,21 @@ form input:disabled, select:disabled, textarea:disabled {
syntax: "*";
inherits: false;
}
+@property --tw-scale-x {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
+@property --tw-scale-y {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
+@property --tw-scale-z {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -5099,6 +5255,9 @@ form input:disabled, select:disabled, textarea:disabled {
--tw-backdrop-sepia: initial;
--tw-duration: initial;
--tw-ease: initial;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
}
}
}
diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js
new file mode 100644
index 0000000..44d190b
--- /dev/null
+++ b/games/static/js/filter_bar.js
@@ -0,0 +1,380 @@
+/**
+ * Filter bar — vanilla JavaScript implementation.
+ *
+ * Handles form submission, preset loading/saving, and preset list rendering.
+ * No HTMX — plain fetch() and window.location for all interactions.
+ */
+(function () {
+ "use strict";
+
+ /** Build a criterion object from a value and optional second value. */
+ function criterion(value, value2, modifier) {
+ var c = { value: value, modifier: modifier };
+ if (value2 !== null && value2 !== undefined && value2 !== "") {
+ c.value2 = value2;
+ }
+ return c;
+ }
+
+ /** Read a