Add filters
This commit is contained in:
+518
-1
@@ -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"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
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:
|
||||
"</button>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
# ── 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'<input type="range" class="range-min absolute w-full pointer-events-none '
|
||||
f'appearance-none bg-transparent h-2 '
|
||||
f''
|
||||
f'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 '
|
||||
f'[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full '
|
||||
f'[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer '
|
||||
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
|
||||
f'data-target="{min_id}" data-peer="{max_id}" '
|
||||
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">'
|
||||
f'<input type="range" class="range-max absolute w-full pointer-events-none '
|
||||
f'appearance-none bg-transparent h-2 '
|
||||
f''
|
||||
f'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 '
|
||||
f'[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full '
|
||||
f'[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer '
|
||||
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
|
||||
f'data-target="{max_id}" data-peer="{min_id}" '
|
||||
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
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('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
|
||||
'stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" '
|
||||
'd="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 '
|
||||
'1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 '
|
||||
'1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'),
|
||||
"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'<span class="sf-modifier-tag active" data-modifier="{mod_val}">'
|
||||
f"{mod_label}</span> "
|
||||
)
|
||||
else:
|
||||
inactive_mod_html += (
|
||||
f'<div class="sf-option sf-modifier-option" data-modifier="{mod_val}" '
|
||||
f'data-label="{mod_label}">'
|
||||
f'<span class="sf-option-label">{mod_label}</span></div>'
|
||||
)
|
||||
|
||||
selected_html = ""
|
||||
for val in selected:
|
||||
label = _find_label(options, val)
|
||||
selected_html += (
|
||||
f'<span class="sf-tag" data-value="{escape(val)}" data-type="include">'
|
||||
f'<span class="sf-tag-text">\u2713 {escape(label)}</span>'
|
||||
f'<button type="button" class="sf-remove">\u00d7</button></span> '
|
||||
)
|
||||
for val in excluded:
|
||||
label = _find_label(options, val)
|
||||
selected_html += (
|
||||
f'<span class="sf-tag sf-excluded" data-value="{escape(val)}" data-type="exclude">'
|
||||
f'<span class="sf-tag-text">\u2717 {escape(label)}</span>'
|
||||
f'<button type="button" class="sf-remove">\u00d7</button></span> '
|
||||
)
|
||||
|
||||
options_html = ""
|
||||
for val, label in options:
|
||||
options_html += (
|
||||
f'<div class="sf-option" data-value="{escape(val)}" data-label="{escape(label)}">'
|
||||
f'<span class="sf-option-label">{escape(label)}</span>'
|
||||
f'<span class="sf-option-buttons">'
|
||||
f'<button type="button" class="sf-btn-include" data-action="include" title="Include">+</button>'
|
||||
f'<button type="button" class="sf-btn-exclude" data-action="exclude" title="Exclude">\u2212</button>'
|
||||
f"</span></div>"
|
||||
)
|
||||
|
||||
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('<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'+f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mi}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mx}" style="left:100%"></div>'),
|
||||
])
|
||||
|
||||
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('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'), "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('<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'+f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mi}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mx}" style="left:100%"></div>'),
|
||||
])
|
||||
|
||||
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('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'), "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..."])]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user