Add filters
This commit is contained in:
@@ -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)
|
||||
+2
-1
@@ -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"),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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()})"
|
||||
|
||||
+161
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <select> element's value, or "" if not found. */
|
||||
function selectValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
/** Read an <input type="number"> value, or "" if not found. */
|
||||
function numberValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (!el || el.value === "") return "";
|
||||
var val = parseFloat(el.value);
|
||||
return isNaN(val) ? "" : val;
|
||||
}
|
||||
|
||||
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||
function checkedValues(form, name) {
|
||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||
var ids = [];
|
||||
els.forEach(function (el) {
|
||||
var v = parseInt(el.value, 10);
|
||||
if (!isNaN(v)) ids.push(v);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the filter JSON object from form field values.
|
||||
* Returns a plain object ready for JSON.stringify.
|
||||
*/
|
||||
function buildFilterJSON(form) {
|
||||
// Read all SelectableFilter widgets first
|
||||
readSelectableFilters(form);
|
||||
|
||||
var filter = {};
|
||||
var yearMin = numberValue(form, "filter-year-min");
|
||||
var yearMax = numberValue(form, "filter-year-max");
|
||||
var playMin = numberValue(form, "filter-playtime-min");
|
||||
var playMax = numberValue(form, "filter-playtime-max");
|
||||
var mastered = form.querySelector('[name="filter-mastered"]');
|
||||
|
||||
// ── Search field ──
|
||||
var searchInput = form.querySelector('[name="filter-search"]');
|
||||
if (searchInput && searchInput.value.trim()) {
|
||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||
}
|
||||
|
||||
// ── Generic SelectableFilter widgets ──
|
||||
readSelectableFilters(form);
|
||||
var widgets = form.querySelectorAll("[data-selectable-filter]");
|
||||
widgets.forEach(function (w) {
|
||||
var field = w.getAttribute("data-selectable-filter");
|
||||
var inc = parseJSONAttr(w, "data-included");
|
||||
var exc = parseJSONAttr(w, "data-excluded");
|
||||
var mod = w.getAttribute("data-modifier");
|
||||
if (mod === "NOT_NULL" || mod === "IS_NULL") {
|
||||
filter[field] = { modifier: mod };
|
||||
} else if (inc.length > 0 || exc.length > 0) {
|
||||
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
|
||||
filter[field] = {
|
||||
value: isIdField ? inc.map(Number) : inc,
|
||||
excludes: isIdField ? exc.map(Number) : exc,
|
||||
modifier: mod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session-specific fields ──
|
||||
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
|
||||
|
||||
// Game (sessions page)
|
||||
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
|
||||
if (gameWidget) {
|
||||
var gIncluded = parseJSONAttr(gameWidget, "data-included");
|
||||
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
|
||||
var gMod = gameWidget.getAttribute("data-modifier");
|
||||
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
|
||||
filter.game = { modifier: gMod };
|
||||
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
|
||||
filter.game = {
|
||||
value: gIncluded.map(Number),
|
||||
excludes: gExcluded.map(Number),
|
||||
modifier: gMod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Device (sessions page)
|
||||
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
|
||||
if (deviceWidget) {
|
||||
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
|
||||
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
|
||||
var dMod = deviceWidget.getAttribute("data-modifier");
|
||||
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
|
||||
filter.device = { modifier: dMod };
|
||||
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
|
||||
filter.device = {
|
||||
value: dIncluded.map(Number),
|
||||
excludes: dExcluded.map(Number),
|
||||
modifier: dMod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emulated checkbox (sessions page)
|
||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
||||
if (emulated && emulated.checked) {
|
||||
filter.emulated = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
// Active checkbox (sessions page)
|
||||
var active = form.querySelector('[name="filter-active"]');
|
||||
if (active && active.checked) {
|
||||
filter.is_active = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
if (yearMin !== "" && yearMax !== "") {
|
||||
// Skip if both equal the data range extremes (no real filter)
|
||||
var yrMinNum = parseInt(yearMin, 10);
|
||||
var yrMaxNum = parseInt(yearMax, 10);
|
||||
if (yrMinNum === yrMaxNum) {
|
||||
// don't add filter
|
||||
} else {
|
||||
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
|
||||
}
|
||||
} else if (yearMin !== "") {
|
||||
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
||||
} else if (yearMax !== "") {
|
||||
filter.year_released = criterion(yearMax, null, "LESS_THAN");
|
||||
}
|
||||
|
||||
if (playMin !== "" || playMax !== "") {
|
||||
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
|
||||
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
|
||||
// Skip if both are 0 — means slider is at default (no real filter)
|
||||
if (pMin === 0 && pMax === 0) {
|
||||
// don't add filter
|
||||
} else {
|
||||
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
|
||||
if (playMin !== "" && playMax !== "") {
|
||||
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
|
||||
} else if (playMin !== "") {
|
||||
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
|
||||
} else if (playMax !== "") {
|
||||
filter[durKey] = criterion(pMax, null, "LESS_THAN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mastered && mastered.checked) {
|
||||
filter.mastered = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Extract the current page's base URL (without query string). */
|
||||
function baseUrl() {
|
||||
return window.location.pathname;
|
||||
}
|
||||
|
||||
/** Safely parse a JSON attribute, returning empty array on failure. */
|
||||
function parseJSONAttr(el, attr) {
|
||||
var raw = el.getAttribute(attr);
|
||||
if (!raw) return [];
|
||||
try { return JSON.parse(raw); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on filter bar form submit.
|
||||
* Serializes filter fields, navigates to URL with filter param.
|
||||
*/
|
||||
window.applyFilterBar = function (event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
var filter = buildFilterJSON(form);
|
||||
var filterStr = JSON.stringify(filter);
|
||||
var url = baseUrl();
|
||||
if (filterStr && filterStr !== "{}") {
|
||||
url += "?filter=" + encodeURIComponent(filterStr);
|
||||
}
|
||||
window.location.href = url;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all filter fields and reload the unfiltered view.
|
||||
*/
|
||||
window.clearFilterBar = function (formId, filterInputId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
window.location.href = baseUrl();
|
||||
};
|
||||
|
||||
// ── Presets ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch and render the preset list. */
|
||||
function loadPresets() {
|
||||
var dropdown = document.getElementById("preset-dropdown");
|
||||
if (!dropdown) return;
|
||||
var url = dropdown.getAttribute("data-preset-list-url");
|
||||
if (!url) return;
|
||||
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
|
||||
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed to load presets");
|
||||
return r.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
dropdown.innerHTML = html;
|
||||
// Re-attach delete handlers (list_presets view uses onclick attributes,
|
||||
// but we also need to wire up inline handlers if they use data attributes)
|
||||
setupPresetDeleteHandlers(dropdown);
|
||||
})
|
||||
.catch(function (err) {
|
||||
dropdown.innerHTML =
|
||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire up click handlers for preset delete buttons. */
|
||||
function setupPresetDeleteHandlers(container) {
|
||||
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
|
||||
deleteLinks.forEach(function (link) {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var presetId = link.getAttribute("data-delete-preset");
|
||||
var deleteUrl = link.getAttribute("href");
|
||||
if (!deleteUrl) return;
|
||||
if (!confirm("Delete this preset?")) return;
|
||||
fetch(deleteUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": getCsrfToken() },
|
||||
})
|
||||
.then(function () {
|
||||
// Remove the parent <li>
|
||||
var li = link.closest("li");
|
||||
if (li) li.remove();
|
||||
// If no items left, show empty message
|
||||
var ul = container.querySelector("ul");
|
||||
if (ul && ul.querySelectorAll("li").length === 0) {
|
||||
ul.innerHTML =
|
||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Delete failed:", err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the preset name input field and the confirm button. */
|
||||
window.showPresetNameInput = function () {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (input) input.classList.remove("hidden");
|
||||
if (saveBtn) saveBtn.classList.add("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.remove("hidden");
|
||||
if (input) input.focus();
|
||||
};
|
||||
|
||||
/** Save the current filter as a named preset. */
|
||||
window.savePreset = function (formId, filterInputId, saveUrl) {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var name = input ? input.value.trim() : "";
|
||||
if (!name) {
|
||||
if (input) input.classList.add("border-red-500");
|
||||
return;
|
||||
}
|
||||
|
||||
var filterInput = document.getElementById(filterInputId);
|
||||
var form = document.getElementById(formId);
|
||||
var filterObj = form ? buildFilterJSON(form) : {};
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
body.append("mode", mode);
|
||||
body.append("filter", JSON.stringify(filterObj));
|
||||
|
||||
fetch(saveUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Save failed");
|
||||
// Reset UI
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.classList.add("hidden");
|
||||
input.classList.remove("border-red-500");
|
||||
}
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (saveBtn) saveBtn.classList.remove("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.add("hidden");
|
||||
// Refresh the preset list
|
||||
loadPresets();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Failed to save preset:", err);
|
||||
});
|
||||
};
|
||||
|
||||
/** Extract CSRF token from the page. */
|
||||
function getCsrfToken() {
|
||||
var cookie = document.cookie
|
||||
.split("; ")
|
||||
.find(function (row) {
|
||||
return row.startsWith("csrftoken=");
|
||||
});
|
||||
if (cookie) return cookie.split("=")[1];
|
||||
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject search inputs into filter forms ──
|
||||
function injectSearchInputs() {
|
||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "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";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
});
|
||||
}
|
||||
injectSearchInputs();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Dual-handle range slider — pure JS with draggable handles.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
var track = slider.querySelector(".range-track-fill");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
|
||||
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
|
||||
var dMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
|
||||
function percentToValue(p) {
|
||||
var raw = dMin + (p / 100) * (dMax - dMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
|
||||
function setTargetVal(el, v) { if (el) el.value = v; }
|
||||
|
||||
function update() {
|
||||
var minV = getTargetVal(minTarget);
|
||||
var maxV = getTargetVal(maxTarget);
|
||||
minV = clamp(minV, dMin, dMax);
|
||||
maxV = clamp(maxV, dMin, dMax);
|
||||
if (minV > maxV) minV = maxV;
|
||||
if (maxV < minV) maxV = minV;
|
||||
setTargetVal(minTarget, minV);
|
||||
setTargetVal(maxTarget, maxV);
|
||||
var minP = valueToPercent(minV);
|
||||
var maxP = valueToPercent(maxV);
|
||||
minHandle.style.left = minP + "%";
|
||||
maxHandle.style.left = maxP + "%";
|
||||
if (track) {
|
||||
track.style.left = minP + "%";
|
||||
track.style.width = (maxP - minP) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var v = percentToValue(clamp(pct, 0, 100));
|
||||
if (isMin) {
|
||||
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
|
||||
} else {
|
||||
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
|
||||
}
|
||||
update();
|
||||
// Trigger input event on the target so any listeners fire
|
||||
var tgt = isMin ? minTarget : maxTarget;
|
||||
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// Sync from inputs to slider
|
||||
function fromInputs() { update(); }
|
||||
if (minTarget) minTarget.addEventListener("input", fromInputs);
|
||||
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
|
||||
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
// Expose for manual re-init (filter bar toggle)
|
||||
window.initRangeSliders = initAll;
|
||||
})();
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* SelectableFilter widget — Stash-style choice filter with search,
|
||||
* include/exclude buttons, and modifier tags (Any / None).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
|
||||
if (el._sfInit) return;
|
||||
el._sfInit = true;
|
||||
initWidget(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector(".sf-search");
|
||||
var options = container.querySelector(".sf-options");
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
if (!search || !options || !selectedArea) return;
|
||||
|
||||
// ── Search ──
|
||||
search.addEventListener("input", function () {
|
||||
var q = search.value.toLowerCase();
|
||||
options.querySelectorAll(".sf-option").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ── Include / Exclude clicks ──
|
||||
options.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("button");
|
||||
if (btn) {
|
||||
var action = btn.getAttribute("data-action");
|
||||
var itemEl = btn.closest(".sf-option");
|
||||
if (!itemEl) return;
|
||||
var value = itemEl.getAttribute("data-value");
|
||||
var label = itemEl.getAttribute("data-label");
|
||||
if (!value) return;
|
||||
if (action === "include") addTag(container, value, label, "include");
|
||||
else if (action === "exclude") addTag(container, value, label, "exclude");
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on modifier option (not a button)
|
||||
var modOption = e.target.closest(".sf-modifier-option");
|
||||
if (modOption) {
|
||||
var modVal = modOption.getAttribute("data-modifier");
|
||||
setModifier(container, modVal);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Remove selected tag ──
|
||||
selectedArea.addEventListener("click", function (e) {
|
||||
var removeBtn = e.target.closest(".sf-remove");
|
||||
if (removeBtn) {
|
||||
removeBtn.closest(".sf-tag").remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on active modifier tag → deselect it
|
||||
var modTag = e.target.closest(".sf-modifier-tag");
|
||||
if (modTag) {
|
||||
clearModifier(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Add a tag to the selected area and clear modifier. */
|
||||
function addTag(container, value, label, type) {
|
||||
clearModifier(container);
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Check if already present
|
||||
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
|
||||
if (existing) {
|
||||
if (existing.getAttribute("data-type") !== type) {
|
||||
existing.setAttribute("data-type", type);
|
||||
existing.classList.toggle("sf-excluded", type === "exclude");
|
||||
var text = existing.querySelector(".sf-tag-text");
|
||||
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
|
||||
tag.setAttribute("data-value", value);
|
||||
tag.setAttribute("data-type", type);
|
||||
tag.innerHTML =
|
||||
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
|
||||
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
|
||||
selectedArea.appendChild(tag);
|
||||
}
|
||||
|
||||
/** Set a modifier (Any / None) — clears all tags. */
|
||||
function setModifier(container, modVal) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Clear all tags
|
||||
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Clear existing modifier tag
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Add new modifier tag
|
||||
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-modifier-tag active";
|
||||
tag.setAttribute("data-modifier", modVal);
|
||||
tag.textContent = label;
|
||||
selectedArea.appendChild(tag);
|
||||
|
||||
container.setAttribute("data-modifier", modVal);
|
||||
}
|
||||
|
||||
/** Clear any active modifier, removing the tag. */
|
||||
function clearModifier(container) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
|
||||
// Read selections for form submission
|
||||
window.readSelectableFilters = function (form) {
|
||||
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
|
||||
var modifier = container.getAttribute("data-modifier");
|
||||
var modTag = container.querySelector(".sf-modifier-tag.active");
|
||||
if (modTag) modifier = modTag.getAttribute("data-modifier");
|
||||
|
||||
var included = [];
|
||||
var excluded = [];
|
||||
container.querySelectorAll(".sf-tag").forEach(function (tag) {
|
||||
var val = tag.getAttribute("data-value");
|
||||
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
|
||||
else included.push(val);
|
||||
});
|
||||
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
})();
|
||||
+14
-4
@@ -2,6 +2,7 @@ from django.urls import path
|
||||
|
||||
from games.views import (
|
||||
device,
|
||||
filter_presets,
|
||||
game,
|
||||
general,
|
||||
platform,
|
||||
@@ -160,9 +161,18 @@ urlpatterns = [
|
||||
name="list_statuschanges",
|
||||
),
|
||||
path("stats/", general.stats_alltime, name="stats_alltime"),
|
||||
path("stats/<int:year>", general.stats, name="stats_by_year"),
|
||||
# Filter presets
|
||||
path("filter/presets/list", filter_presets.list_presets, name="list_presets"),
|
||||
path("filter/presets/save", filter_presets.save_preset, name="save_preset"),
|
||||
path(
|
||||
"stats/<int:year>",
|
||||
general.stats,
|
||||
name="stats_by_year",
|
||||
"filter/presets/<int:preset_id>/delete",
|
||||
filter_presets.delete_preset,
|
||||
name="delete_preset",
|
||||
),
|
||||
]
|
||||
path(
|
||||
"filter/presets/<int:preset_id>/load",
|
||||
filter_presets.load_preset,
|
||||
name="load_preset",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Views for managing saved filter presets (FilterPreset model)."""
|
||||
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from games.models import FilterPreset
|
||||
|
||||
|
||||
@login_required
|
||||
def list_presets(request: HttpRequest) -> HttpResponse:
|
||||
"""Return a preset dropdown as an HTML fragment."""
|
||||
mode = request.GET.get("mode", "games")
|
||||
presets = FilterPreset.objects.filter(mode=mode).order_by("name")
|
||||
|
||||
items: list[str] = []
|
||||
for preset in presets:
|
||||
filter_json = (
|
||||
json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
)
|
||||
list_url = reverse(f"games:list_{mode}")
|
||||
delete_url = reverse("games:delete_preset", args=[preset.id])
|
||||
|
||||
items.append(
|
||||
f"<li>"
|
||||
f'<a href="{list_url}?filter={quote(filter_json)}" '
|
||||
f'class="flex justify-between items-center px-4 py-2 text-sm '
|
||||
f'text-heading hover:bg-neutral-secondary-medium">'
|
||||
f"<span>{preset.name}</span>"
|
||||
f'<span class="text-red-500 hover:text-red-700 cursor-pointer ml-4" '
|
||||
f'data-delete-preset="{preset.id}" '
|
||||
f'href="{delete_url}">x</span>'
|
||||
f"</a></li>"
|
||||
)
|
||||
|
||||
if not items:
|
||||
items = [
|
||||
'<li class="px-4 py-2 text-sm text-body italic">'
|
||||
"No saved presets</li>"
|
||||
]
|
||||
|
||||
return HttpResponse(
|
||||
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def save_preset(request: HttpRequest) -> HttpResponse:
|
||||
"""Save the current filter as a new preset."""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
|
||||
name = request.POST.get("name", "").strip()
|
||||
mode = request.POST.get("mode", "games")
|
||||
filter_json_str = request.POST.get("filter", "")
|
||||
|
||||
if not name:
|
||||
messages.error(request, "Preset name is required.")
|
||||
return HttpResponse(status=400)
|
||||
|
||||
object_filter: dict = {}
|
||||
if filter_json_str:
|
||||
try:
|
||||
object_filter = json.loads(filter_json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
FilterPreset.objects.create(
|
||||
name=name,
|
||||
mode=mode,
|
||||
object_filter=object_filter,
|
||||
)
|
||||
messages.success(request, f'Filter preset "{name}" saved.')
|
||||
return HttpResponse(status=201)
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
|
||||
"""Delete a saved filter preset."""
|
||||
preset = get_object_or_404(FilterPreset, id=preset_id)
|
||||
name = preset.name
|
||||
preset.delete()
|
||||
messages.success(request, f'Preset "{name}" deleted.')
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
def load_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
|
||||
"""Load a preset and redirect to the appropriate list view."""
|
||||
preset = get_object_or_404(FilterPreset, id=preset_id)
|
||||
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
return redirect(
|
||||
f"{reverse(f'games:list_{preset.mode}')}?filter={quote(filter_json)}"
|
||||
)
|
||||
+44
-21
@@ -18,6 +18,7 @@ from common.components import (
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
FilterBar,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
H1,
|
||||
@@ -42,6 +43,7 @@ from common.time import (
|
||||
timeformat,
|
||||
)
|
||||
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
|
||||
from games.filters import parse_game_filter
|
||||
from games.forms import GameForm
|
||||
from games.models import Game
|
||||
from games.views.general import use_custom_redirect
|
||||
@@ -51,26 +53,35 @@ from games.views.playevent import create_playevent_tabledata
|
||||
@login_required
|
||||
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
games = Game.objects.order_by("-created_at")
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
filters = [
|
||||
Q(name__icontains=search_string),
|
||||
Q(sort_name__icontains=search_string),
|
||||
Q(platform__name__icontains=search_string),
|
||||
]
|
||||
try:
|
||||
year_value = int(search_string)
|
||||
except ValueError:
|
||||
year_value = None
|
||||
if year_value:
|
||||
filters.append(Q(year_released=year_value))
|
||||
search_string_parts = search_string.split()
|
||||
# only search for status if it exactly matches and is the only word
|
||||
if len(search_string_parts) == 1:
|
||||
if search_string.title() in Game.Status.labels:
|
||||
search_status = Game.Status[search_string.upper()]
|
||||
filters.append(Q(status=search_status))
|
||||
games = games.filter(build_dynamic_filter(filters, "|"))
|
||||
|
||||
# ── Structured filter (Stash-style JSON) ──
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
game_filter = parse_game_filter(filter_json)
|
||||
if game_filter is not None:
|
||||
games = games.filter(game_filter.to_q())
|
||||
else:
|
||||
# ── Legacy free-text search ──
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
filters = [
|
||||
Q(name__icontains=search_string),
|
||||
Q(sort_name__icontains=search_string),
|
||||
Q(platform__name__icontains=search_string),
|
||||
]
|
||||
try:
|
||||
year_value = int(search_string)
|
||||
except ValueError:
|
||||
year_value = None
|
||||
if year_value:
|
||||
filters.append(Q(year_released=year_value))
|
||||
search_string_parts = search_string.split()
|
||||
if len(search_string_parts) == 1:
|
||||
if search_string.title() in Game.Status.labels:
|
||||
search_status = Game.Status[search_string.upper()]
|
||||
filters.append(Q(status=search_status))
|
||||
games = games.filter(build_dynamic_filter(filters, "|"))
|
||||
|
||||
games, page_obj, elided_page_range = paginate(request, games)
|
||||
|
||||
data = {
|
||||
@@ -126,7 +137,19 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage games")
|
||||
# Prepend the filter bar above the table
|
||||
filter_bar = FilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage games",
|
||||
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+24
-5
@@ -12,7 +12,7 @@ from django.views.decorators.http import require_POST
|
||||
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.safestring import SafeText
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
@@ -95,9 +95,16 @@ def _render_purchase_row(purchase):
|
||||
|
||||
@login_required
|
||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
purchases, page_obj, elided_page_range = paginate(
|
||||
request, Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||
)
|
||||
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_purchase_filter
|
||||
pf = parse_purchase_filter(filter_json)
|
||||
if pf is not None:
|
||||
purchases = purchases.filter(pf.to_q())
|
||||
|
||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
@@ -121,7 +128,19 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage purchases")
|
||||
from common.components import PurchaseFilterBar, ModuleScript
|
||||
filter_bar = PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage purchases",
|
||||
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
def _purchase_additional_row() -> SafeText:
|
||||
|
||||
+33
-10
@@ -40,15 +40,25 @@ from games.models import Device, Game, Session
|
||||
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||
device_list = Device.objects.order_by("name")
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
sessions = sessions.filter(
|
||||
Q(game__name__icontains=search_string)
|
||||
| Q(game__name__icontains=search_string)
|
||||
| Q(game__platform__name__icontains=search_string)
|
||||
| Q(device__name__icontains=search_string)
|
||||
| Q(device__type__icontains=search_string)
|
||||
)
|
||||
|
||||
# ── Structured filter (JSON) ──
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_session_filter
|
||||
session_filter = parse_session_filter(filter_json)
|
||||
if session_filter is not None:
|
||||
sessions = sessions.filter(session_filter.to_q())
|
||||
else:
|
||||
# ── Legacy free-text search ──
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
sessions = sessions.filter(
|
||||
Q(game__name__icontains=search_string)
|
||||
| Q(game__name__icontains=search_string)
|
||||
| Q(game__platform__name__icontains=search_string)
|
||||
| Q(device__name__icontains=search_string)
|
||||
| Q(device__type__icontains=search_string)
|
||||
)
|
||||
try:
|
||||
last_session = sessions.latest()
|
||||
except Session.DoesNotExist:
|
||||
@@ -157,7 +167,20 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage sessions")
|
||||
from common.components import SessionFilterBar
|
||||
filter_json = request.GET.get("filter", "")
|
||||
filter_bar = SessionFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage sessions",
|
||||
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
Reference in New Issue
Block a user