Compare commits

...

3 Commits

Author SHA1 Message Date
lukas b6864e59ce Add filters
Django CI/CD / test (push) Successful in 43s
Django CI/CD / build-and-push (push) Successful in 1m22s
2026-06-06 12:13:04 +02:00
lukas 36b1382015 Fix code smells 2026-06-06 08:15:19 +02:00
lukas d101aecd70 Move from HTML templates to pure Python
Remove cruft
2026-06-06 07:51:10 +02:00
119 changed files with 5644 additions and 3037 deletions
+48
View File
@@ -0,0 +1,48 @@
with open("common/components.py", "r") as f:
content = f.read()
# Count FilterBar functions to know which replacement targets which
n = content.count('("name", "filter"), ("value", escape(filter_json))')
print(f"Found {n} hidden filter inputs")
# Simple: after each hidden filter input, insert a search input
search_html = ''' Component(tag_name="input", attributes=[
("type", "text"), ("name", "filter-search"),
("value", escape(search_val)),
("placeholder", "Search\u2026"),
("class", "block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 "
"focus:ring-brand focus:border-brand"),
]),
'''
old = ''' Component(tag_name="input", attributes=[
("type", "hidden"), ("id", filter_input_id),
("name", "filter"), ("value", escape(filter_json)),
]),
Component(tag_name="div", attributes=['''
# Only replace occurrences in FilterBar functions (after 'def FilterBar' or 'def SessionFilterBar' or 'def PurchaseFilterBar')
# Find each occurrence and replace
import re
# Strategy: split by the old pattern, insert search_html between first two parts of each split
parts = content.split(old)
print(f"Split into {len(parts)} parts")
new_content = parts[0]
for i in range(1, len(parts)):
# Check if this occurrence is inside a FilterBar function (not inside SelectableFilter)
# Simple heuristic: the context before should contain 'FilterBar'
chunk_before = parts[i-1][-500:] if len(parts[i-1]) > 500 else parts[i-1]
is_filterbar = 'FilterBar' in chunk_before or 'filter_bar' in chunk_before.lower()
if is_filterbar:
new_content += old + search_html + parts[i]
else:
new_content += old + parts[i]
with open("common/components.py", "w") as f:
f.write(new_content)
import ast
ast.parse(new_content)
print("OK")
-46
View File
@@ -1,46 +0,0 @@
# Suggested Improvements to common/components.py
## Completed
### Caching on template rendering
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
- Verified working: identical calls return identical output, different inputs produce separate cache entries
### Non-deterministic IDs
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
- `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
### Inconsistent return types
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
### Fragile A() URL resolution
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
### Toast XSS vulnerability
The vulnerable `Toast()` component (which used unsafe string escaping for
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
headers → `show-toast` CustomEvent → Alpine store.
### Default mutable arguments
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
### NameWithIcon dead code and untestable design
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
### No tests
Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification.
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
+880 -105
View File
File diff suppressed because it is too large Load Diff
+451
View File
@@ -0,0 +1,451 @@
"""
Typed criterion inputs for building structured filters.
Inspired by Stash's filter architecture: every filterable field uses a typed
criterion with a value and a CriterionModifier. This separates *what* you're
filtering from *how* you're comparing, and makes filter serialization trivial.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, fields as dc_fields
from enum import Enum
from typing import Any, Self, TypeVar
from django.db.models import Q
# ── Modifier ──────────────────────────────────────────────────────────────
class Modifier(str, Enum):
"""Comparison operators shared across all criterion types."""
EQUALS = "EQUALS"
NOT_EQUALS = "NOT_EQUALS"
GREATER_THAN = "GREATER_THAN"
LESS_THAN = "LESS_THAN"
BETWEEN = "BETWEEN"
NOT_BETWEEN = "NOT_BETWEEN"
INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL"
IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX"
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
@classmethod
def for_strings(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.INCLUDES, cls.EXCLUDES,
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
cls.IS_NULL, cls.NOT_NULL,
]
@classmethod
def for_numbers(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.GREATER_THAN, cls.LESS_THAN,
cls.BETWEEN, cls.NOT_BETWEEN,
cls.IS_NULL, cls.NOT_NULL,
]
@classmethod
def for_dates(cls) -> list[Self]:
return cls.for_numbers()
@classmethod
def for_multi(cls) -> list[Self]:
return [
cls.INCLUDES, cls.EXCLUDES,
cls.INCLUDES_ALL,
cls.IS_NULL, cls.NOT_NULL,
]
# ── Base criterion ─────────────────────────────────────────────────────────
T = TypeVar("T")
@dataclass
class _Criterion:
"""Base for all typed criteria."""
value: Any = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
raise NotImplementedError
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name in data:
val = data[f.name]
# Coerce string modifier to Modifier enum
if f.name == "modifier" and isinstance(val, str):
val = Modifier(val)
kwargs[f.name] = val
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is not None and v != f.default:
result[f.name] = v
return result
# ── Concrete criteria ──────────────────────────────────────────────────────
@dataclass
class StringCriterion(_Criterion):
value: str = ""
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.INCLUDES:
return Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.MATCHES_REGEX:
return Q(**{f"{field_name}__regex": self.value})
if m == Modifier.NOT_MATCHES_REGEX:
return ~Q(**{f"{field_name}__regex": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for string field")
@dataclass
class IntCriterion(_Criterion):
value: int = 0
value2: int | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for int field")
@dataclass
class FloatCriterion(_Criterion):
value: float = 0.0
value2: float | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for float field")
@dataclass
class DateCriterion(_Criterion):
value: str = ""
value2: str | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": self.value,
f"{field_name}__lte": self.value2})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for date field")
@dataclass
class BoolCriterion(_Criterion):
value: bool = False
# Bool only makes sense with EQUALS
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
if self.modifier == Modifier.EQUALS:
return Q(**{field_name: self.value})
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
@dataclass
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL:
q = Q()
for v in self.value:
q &= Q(**{field_name: v})
return q
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ────────────────────────────────────────────────────
F = TypeVar("F", bound="OperatorFilter")
@dataclass
class OperatorFilter:
"""Mixin providing AND/OR/NOT composition for entity filter types.
Subclasses should declare nullable references to themselves::
@dataclass
class GameFilter(OperatorFilter):
AND: "GameFilter | None" = None
OR: "GameFilter | None" = None
NOT: "GameFilter | None" = None
name: StringCriterion | None = None
...
"""
def sub_filter(self) -> OperatorFilter | None:
"""Return the first non-None of AND / OR / NOT."""
for attr in ("AND", "OR", "NOT"):
if hasattr(self, attr):
v = getattr(self, attr)
if v is not None:
return v
return None
def _criterion_fields(self) -> list[str]:
"""Return field names that hold a _Criterion instance."""
names: list[str] = []
for f in dc_fields(self):
if f.name in ("AND", "OR", "NOT"):
continue
v = getattr(self, f.name)
if isinstance(v, _Criterion):
names.append(f.name)
return names
def to_q(self) -> Q:
"""Build a Django Q object from this filter and its sub-filters."""
q = Q()
for field_name in self._criterion_fields():
c = getattr(self, field_name)
if c is not None:
q &= c.to_q(field_name)
sub = self.sub_filter()
if sub is not None:
if getattr(self, "AND", None) is not None:
q &= sub.to_q()
elif getattr(self, "OR", None) is not None:
q |= sub.to_q()
elif getattr(self, "NOT", None) is not None:
q &= ~sub.to_q()
return q
@classmethod
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
# Resolve criterion class names to actual types
criterion_types: dict[str, type[_Criterion]] = {
"StringCriterion": StringCriterion,
"IntCriterion": IntCriterion,
"FloatCriterion": FloatCriterion,
"DateCriterion": DateCriterion,
"BoolCriterion": BoolCriterion,
"MultiCriterion": MultiCriterion,
"ChoiceCriterion": ChoiceCriterion,
}
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name not in data:
continue
raw = data[f.name]
if raw is None:
kwargs[f.name] = None
continue
# Recurse into sub-filters (AND / OR / NOT)
if f.name in ("AND", "OR", "NOT"):
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
continue
# Resolve criterion fields from string type annotation
f_type = f.type
if isinstance(f_type, str):
# e.g. "StringCriterion | None" → "StringCriterion"
f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type]
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is None:
continue
if f.name in ("AND", "OR", "NOT"):
result[f.name] = v.to_json()
elif isinstance(v, _Criterion):
j = v.to_json()
if j:
result[f.name] = j
return result
# ── JSON helpers ───────────────────────────────────────────────────────────
def filter_from_json(cls: type[F], json_str: str) -> F | None:
"""Deserialize a filter from a JSON string.
Usage:
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
games = Game.objects.filter(f.to_q())
"""
if not json_str:
return None
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return None
return cls.from_json(data)
def filter_to_json(f: OperatorFilter) -> str:
"""Serialize a filter to a JSON string for URL params or storage."""
return json.dumps(f.to_json())
+1 -1
View File
@@ -2,7 +2,7 @@ import functools
from pathlib import Path
_ICON_DIR = (
Path(__file__).resolve().parent.parent / "games" / "templates" / "cotton" / "icon"
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
)
+2 -2
View File
@@ -20,8 +20,8 @@ def import_data(data: DataList):
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
except:
pass
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
game_id = None
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
+353
View File
@@ -0,0 +1,353 @@
"""A small fast_app-style layout system.
Instead of Django template inheritance (`{% extends "base.html" %}`), views
build their page body with Python components and wrap it with `Page()` /
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
"""
import json
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date
# Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>"""
# The main module script: crown icon mount + theme-toggle wiring.
# Split around the single dynamic value (game.mastered).
_MAIN_SCRIPT_A = """<script type="module">
document.addEventListener('DOMContentLoaded', () => {
if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
mastered: """
_MAIN_SCRIPT_B = """
});
}
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
themeToggleBtn.addEventListener('click', function () {
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
}
});
</script>"""
# Toast notification region (Alpine.js). Verbatim from the old base.html.
_TOAST_CONTAINER = """<div x-data="toastStore()"
role="region"
aria-label="Notifications"
aria-atomic="true"
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
<div x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
tabindex="0"
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
:class="{
'success': toast.type === 'success',
'error': toast.type === 'error',
'info': toast.type === 'info',
'warning': toast.type === 'warning',
'debug': toast.type === 'debug'
}"
@click="dismissToast(toast.id)"
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
@keydown.escape="dismissToast(toast.id)">
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
:class="{
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
}">
<span class="flex-shrink-0 mt-0.5"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-blue-500': toast.type === 'info',
'text-amber-500': toast.type === 'warning',
'text-gray-500': toast.type === 'debug'
}">
<template x-if="toast.type === 'success'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
</svg>
</template>
<template x-if="toast.type === 'debug'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</template>
</span>
<p class="flex-1 text-sm"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-blue-800 dark:text-blue-200': toast.type === 'info',
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
}"
x-text="toast.message"></p>
<button @click.stop="dismissToast(toast.id)"
class="flex-shrink-0"
:class="{
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>"""
def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
"""Top navigation bar."""
logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse('games:index')}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
</a>
<button data-collapse-toggle="navbar-dropdown" type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="flex items-center">
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</li>
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
</li>
<li>
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
</li>
<li>
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
New
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse('games:add_device')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
<li><a href="{reverse('games:add_game')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
<li><a href="{reverse('games:add_platform')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
<li><a href="{reverse('games:add_purchase')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
<li><a href="{reverse('games:add_session')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
</ul>
</div>
</li>
<li>
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
Manage
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse('games:list_devices')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
<li><a href="{reverse('games:list_games')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
<li><a href="{reverse('games:list_platforms')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
<li><a href="{reverse('games:list_playevents')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
<li><a href="{reverse('games:list_purchases')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
<li><a href="{reverse('games:list_sessions')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
</ul>
</div>
</li>
<li>
<a href="{reverse('games:stats_by_year', args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{reverse('logout')}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
</li>
</ul>
</div>
</div>
</nav>""")
def Page(
content: SafeText | str,
*,
request: HttpRequest,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
from games.views.general import global_current_year, model_counts
counts = model_counts(request)
year = global_current_year(request)["global_current_year"]
navbar = Navbar(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
)
messages = [
{"message": str(m.message), "type": (m.tags or "info")}
for m in get_messages(request)
]
# Embed as JSON; guard against `</script>` breaking out of the tag.
messages_json = json.dumps(messages).replace("</", "<\\/")
head = (
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
' <meta charset="utf-8" />\n'
' <meta name="description" content="Self-hosted time-tracker." />\n'
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
f' <script src="{static("js/htmx.min.js")}"></script>\n'
" <script>\n"
" htmx.config.scrollBehavior = 'smooth';\n"
" htmx.config.selfRequestsOnly = false;\n"
" </script>\n"
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n"
)
body = (
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
' <div class="flex flex-col min-h-screen">\n'
f" {navbar}\n"
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n"
f" {scripts}\n"
f" {_main_script(mastered)}\n"
' <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n'
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n'
" </body>\n</html>\n"
)
return mark_safe(head + body)
def render_page(
request: HttpRequest,
content: SafeText | str,
*,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
status: int = 200,
) -> HttpResponse:
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
return HttpResponse(
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
status=status,
)
+26 -3
View File
@@ -5,11 +5,34 @@ from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.core.paginator import Page, Paginator
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def paginate(request: HttpRequest, queryset, per_page: int = 10):
"""Standard list-view pagination.
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
to hand to ``paginated_table_content``.
"""
page_number = request.GET.get("page", 1)
limit = int(request.GET.get("limit", per_page))
object_list = queryset
page_obj: Page | None = None
if limit != 0:
page_obj = Paginator(queryset, limit).get_page(page_number)
object_list = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
return object_list, page_obj, elided_page_range
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
@@ -153,9 +176,9 @@ def redirect_to(default_view: str, *default_args):
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
# Execute the original view logic for its side effects, then
# redirect to `next_url` instead of returning its response.
view_func(request, *args, **kwargs)
return redirect(next_url)
return wrapped_view
+384
View File
@@ -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
View File
@@ -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'],
},
),
]
+30 -3
View File
@@ -327,9 +327,6 @@ class Session(models.Model):
def finish_now(self):
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
@@ -481,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 -16
View File
@@ -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);
}
@@ -895,18 +898,12 @@
max-width: 96rem;
}
}
.m-4 {
margin: calc(var(--spacing) * 4);
}
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-auto {
margin-inline: auto;
}
.my-4 {
margin-block: calc(var(--spacing) * 4);
}
.my-6 {
margin-block: calc(var(--spacing) * 6);
}
@@ -1279,6 +1276,9 @@
margin-left: -10px !important;
}
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
.ml-auto {
margin-left: auto;
}
@@ -1437,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);
}
@@ -1467,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;
}
@@ -1574,9 +1583,6 @@
.w-full {
width: 100%;
}
.max-w-\(--breakpoint-lg\) {
max-width: var(--breakpoint-lg);
}
.max-w-\(--breakpoint-xl\) {
max-width: var(--breakpoint-xl);
}
@@ -1665,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);
@@ -1703,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));
}
@@ -1835,6 +1851,9 @@
.rounded-base {
border-radius: var(--radius-base);
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
@@ -1897,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;
@@ -1993,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;
@@ -2099,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);
}
@@ -2296,6 +2331,9 @@
color: heading !important;
}
}
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-16 {
padding-bottom: calc(var(--spacing) * 16);
}
@@ -2455,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;
}
@@ -2509,6 +2551,9 @@
.text-body {
color: var(--color-body);
}
.text-brand {
color: var(--color-brand);
}
.text-fg-brand {
color: var(--color-fg-brand);
}
@@ -2578,6 +2623,9 @@
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2672,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;
}
@@ -2801,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) {
@@ -2871,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) {
@@ -2976,6 +3045,13 @@
}
}
}
.hover\:text-red-700 {
&:hover {
@media (hover: hover) {
color: var(--color-red-700);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -2998,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);
@@ -3091,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 {
@@ -3241,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);
@@ -3778,11 +3865,6 @@
text-underline-offset: 4px;
}
}
.\[\&_h1\]\:mb-2 {
& h1 {
margin-bottom: calc(var(--spacing) * 2);
}
}
.\[\&_li\:first-of-type_a\]\:rounded-none {
& li:first-of-type a {
border-radius: 0;
@@ -3813,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);
@@ -5046,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);
@@ -5113,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;
}
}
}
+380
View File
@@ -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();
});
})();
+96
View File
@@ -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;
})();
+149
View File
@@ -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);
})();
+2 -2
View File
@@ -4,10 +4,10 @@ import requests
from django.db import models
from django.template.defaultfilters import floatformat
logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase
logger = logging.getLogger("games")
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
-2
View File
@@ -1,2 +0,0 @@
<c-layouts.add>
</c-layouts.add>
-9
View File
@@ -1,9 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<c-button type="submit" color="gray"
name="submit_and_redirect"
>
Submit & Create Purchase
</c-button>
</c-slot>
</c-layouts.add>
-15
View File
@@ -1,15 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<c-button type="submit"
color="gray"
name="submit_and_redirect"
>
Submit & Create Session
</c-button>
</td>
</tr>
</c-slot>
</c-layouts.add>
-38
View File
@@ -1,38 +0,0 @@
<c-layouts.add>
<c-slot name="form_content">
<div class="max-width-container">
<div id="add-form" class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data" class="">
{% csrf_token %}
{% for field in form %}
<div>
{{ field.label_tag }}
{% if field.name == "note" %}
{{ field }}
{% else %}
{{ field }}
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
</c-button>
</span>
{% endif %}
</div>
{% endfor %}
<div>
<c-button type="submit">
Submit
</c-button>
</div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form>
</div>
</div>
</c-slot>
</c-layouts.add>
-3
View File
@@ -1,3 +0,0 @@
<c-vars color="blue" size="base" type="button" />
{% load button_tag %}
{% python_button color=color size=size icon=icon type=type class_=class hx_get=hx_get hx_target=hx_target hx_swap=hx_swap title=title onclick=onclick data_target=data_target data_type=data_type name=name slot=slot %}
-2
View File
@@ -1,2 +0,0 @@
{% load button_group_tag %}
{% python_button_group buttons=buttons %}
-2
View File
@@ -1,2 +0,0 @@
{% load gamelink_tag %}
{% python_gamelink game_id=game_id name=name slot=slot %}
-2
View File
@@ -1,2 +0,0 @@
{% load gamestatus_tag %}
{% python_gamestatus status=status display=display class_=class slot=slot %}
-3
View File
@@ -1,3 +0,0 @@
<c-vars badge="" />
{% load h1_tag %}
{% python_h1 badge=badge slot=slot %}
-28
View File
@@ -1,28 +0,0 @@
<c-layouts.base>
{% load static %}
{% if form_content %}
{{ form_content }}
{% else %}
<div id="add-form" class="max-width-container">
<div id="add-form" class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<div>
<c-button type="submit" class="mt-3">
Submit
</c-button>
</div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form>
</div>
</div>
{% endif %}
<c-slot name="scripts">
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
</c-slot>
</c-layouts.base>
-212
View File
@@ -1,212 +0,0 @@
{% load django_htmx %}
<!DOCTYPE html>
<html lang="en">
{% load static %}
<head>
<meta charset="utf-8" />
<meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {{ title }}</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
<script>
htmx.config.scrollBehavior = 'smooth';
htmx.config.selfRequestsOnly = false;
</script>
<script src="{% static 'js/htmx-redirect-toast.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
</head>
<body hx-indicator="#indicator" class="bg-neutral-primary">
<script id="django-messages" type="application/json">
[
{% for message in messages %}
{"message": "{{ message|escapejs }}", "type": "{{ message.tags|default:'info' }}"}{% if not forloop.last %},{% endif %}
{% endfor %}
]
</script>
<img id="indicator"
src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator"
height="24"
width="24"
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
{% include "navbar.html" %}
<div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{{ slot }}</div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{{ scripts }}
<script type="module">
document.addEventListener('DOMContentLoaded', () => {
if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
mastered: {{ game.mastered|yesno:"true,false" }}
});
}
// Theme toggle logic
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
// Ensure all elements are found before proceeding
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
// Initial state of icons based on current theme
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
// So we just need to set the icon visibility based on that.
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else { // current theme is dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else { // no theme in local storage, use system preference
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else { // currently light, switch to dark
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
}
});
</script>
<!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->
<div id="global-modal-container" hx-swap-oob="true"></div>
<div x-data="toastStore()"
role="region"
aria-label="Notifications"
aria-atomic="true"
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
<div x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
tabindex="0"
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
:class="{
'success': toast.type === 'success',
'error': toast.type === 'error',
'info': toast.type === 'info',
'warning': toast.type === 'warning',
'debug': toast.type === 'debug'
}"
@click="dismissToast(toast.id)"
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
@keydown.escape="dismissToast(toast.id)">
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
:class="{
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
}">
<span class="flex-shrink-0 mt-0.5"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-blue-500': toast.type === 'info',
'text-amber-500': toast.type === 'warning',
'text-gray-500': toast.type === 'debug'
}">
<template x-if="toast.type === 'success'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
</svg>
</template>
<template x-if="toast.type === 'debug'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</template>
</span>
<p class="flex-1 text-sm"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-blue-800 dark:text-blue-200': toast.type === 'info',
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
}"
x-text="toast.message"></p>
<button @click.stop="dismissToast(toast.id)"
class="flex-shrink-0"
:class="{
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>
<script src="{% static 'js/toast.js' %}"></script>
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
<c-vars popover_content="" wrapped_content="" wrapped_classes="" id="" />
{% load popover_tag %}
{% python_popover popover_content=popover_content wrapped_content=wrapped_content wrapped_classes=wrapped_classes id=id slot=slot %}
@@ -1,2 +0,0 @@
{% load price_converted_tag %}
{% python_price_converted slot=slot %}
-2
View File
@@ -1,2 +0,0 @@
{% load table_header_tag %}
{% python_table_header slot=slot %}
-2
View File
@@ -1,2 +0,0 @@
{% load table_row_tag %}
{% python_table_row data=data %}
-2
View File
@@ -1,2 +0,0 @@
{% load table_td_tag %}
{% python_table_td slot=slot %}
@@ -1,16 +0,0 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<form method="post" class="dark:text-white">
{% csrf_token %}
<div>
<p>Are you sure you want to delete this status change?</p>
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
<a href="{% url 'games:view_game' object.game.id %}" class="">
<c-button color="gray" class="w-full">Cancel</c-button>
</a>
</div>
</form>
</div>
</c-layouts.base>

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before

Width:  |  Height:  |  Size: 819 B

After

Width:  |  Height:  |  Size: 819 B

Before

Width:  |  Height:  |  Size: 477 B

After

Width:  |  Height:  |  Size: 477 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 571 B

After

Width:  |  Height:  |  Size: 571 B

Before

Width:  |  Height:  |  Size: 798 B

After

Width:  |  Height:  |  Size: 798 B

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 643 B

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 779 B

After

Width:  |  Height:  |  Size: 779 B

Before

Width:  |  Height:  |  Size: 252 B

After

Width:  |  Height:  |  Size: 252 B

Before

Width:  |  Height:  |  Size: 825 B

After

Width:  |  Height:  |  Size: 825 B

Before

Width:  |  Height:  |  Size: 248 B

After

Width:  |  Height:  |  Size: 248 B

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 978 B

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 978 B

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 834 B

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 882 B

After

Width:  |  Height:  |  Size: 882 B

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 879 B

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 669 B

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 345 B

-17
View File
@@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="text-slate-300 mx-auto max-w-(--breakpoint-lg) text-center">
{% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
{% elif not game_available or not platform_available %}
There are no games in the database. Start by clicking "New Game" and "New Platform".
{% elif not purchase_available %}
There are no owned games. Click "New Purchase" at the top.
{% else %}
You haven't played any games yet. Click "New Session" to add one now.
{% endif %}
</div>
{% endblock content %}
-6
View File
@@ -1,6 +0,0 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
{% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
</div>
</c-layouts.base>
-6
View File
@@ -1,6 +0,0 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
{% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
</div>
</c-layouts.base>
-73
View File
@@ -1,73 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="flex-col">
{% if dataset_count >= 1 %}
{% url 'games:list_sessions_start_session_from_session' last.id as start_session_url %}
<div class="mx-auto text-center my-4">
<a id="last-session-start"
href="{{ start_session_url }}"
hx-get="{{ start_session_url }}"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}">
</a>
</div>
{% endif %}
{% if dataset_count != 0 %}
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
</tr>
</thead>
<tbody>
{% for session in dataset %}
{% partialdef session-row inline=True %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative">
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-xs group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
href="{% url 'games:view_game' session.game.id %}">
{{ session.game.name }}
</a>
</span>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ session.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if not session.timestamp_end %}
{% url 'games:list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest tr"
hx-swap="outerHTML"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif session.duration_manual %}
--
{% else %}
{{ session.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td>
</tr>
{% endpartialdef %}
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
</div>
{% endblock content %}
-146
View File
@@ -1,146 +0,0 @@
{% load static %}
<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{% url 'games:index' %}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{% static 'icons/schedule.png' %}"
height="48"
width="48"
alt="Timetracker Logo"
class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
</a>
<button data-collapse-toggle="navbar-dropdown"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown"
aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="flex items-center">
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</li>
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{{ today_played }}<span class="dark:text-gray-400">·</span>{{ last_7_played }}</span>
</li>
<li>
<a href="#"
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
aria-current="page">Home</a>
</li>
<li>
<button id="dropdownNavbarNewLink"
data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
New
<svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownNavbarNew"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton">
<li>
<a href="{% url 'games:add_device' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
</li>
<li>
<a href="{% url 'games:add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li>
<li>
<a href="{% url 'games:add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
</li>
<li>
<a href="{% url 'games:add_purchase' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
</li>
<li>
<a href="{% url 'games:add_session' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
</li>
</ul>
</div>
</li>
<li>
<button id="dropdownNavbarManageLink"
data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
Manage
<svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownNavbarManage"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton">
<li>
<a href="{% url 'games:list_devices' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
</li>
<li>
<a href="{% url 'games:list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li>
<li>
<a href="{% url 'games:list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
</li>
<li>
<a href="{% url 'games:list_playevents' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
</li>
<li>
<a href="{% url 'games:list_purchases' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
</li>
<li>
<a href="{% url 'games:list_sessions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
</li>
</ul>
</div>
</li>
<li>
<a href="{% url 'games:stats_by_year' global_current_year %}"
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{% url 'logout' %}"
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
out</a>
</li>
</ul>
</div>
</div>
</nav>
@@ -1,32 +0,0 @@
{% load modal_tag %}
{% python_modal "delete-game-confirmation-modal" %}
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Delete Game</h1>
<p class="dark:text-white text-center mt-5">
Are you sure you want to delete <strong>{{ game.name }}</strong>?
</p>
<form class=""
hx-post="{% url 'games:delete_game' game.id %}"
hx-replace-url="true"
hx-target="#main-container"
hx-select="#main-container"
hx-swap="outerHTML"
>
{% csrf_token %}
<p class="dark:text-white text-center mt-3 text-sm text-gray-600 dark:text-gray-400">
This will permanently delete this game and all associated data:
</p>
<ul class="dark:text-white text-center mt-1 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
{% if session_count %}<li>{{ session_count }} session(s)</li>{% endif %}
{% if purchase_count %}<li>{{ purchase_count }} purchase(s)</li>{% endif %}
{% if playevent_count %}<li>{{ playevent_count }} play event(s)</li>{% endif %}
{% if not session_count and not purchase_count and not playevent_count %}<li>No associated data</li>{% endif %}
</ul>
<p class="dark:text-white text-center mt-3 text-sm font-medium text-red-600 dark:text-red-400">
This action cannot be undone.
</p>
<div class="items-center mt-5">
<c-button color="red" size="lg" type="submit" class="w-full">Delete</c-button>
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#delete-game-confirmation-modal').remove()">Cancel</c-button>
</div>
</form>
{% endpython_modal %}
@@ -1,49 +0,0 @@
<div class="flex gap-2 items-center"
x-data="{
status: '{{ game.status }}',
status_display: '{{ game.get_status_display }}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers(`/api/games/{{ game.id }}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ status: newStatus })
})
.then(() => {
document.body.dispatchEvent(new CustomEvent('status-changed'));
})
.catch(() => {
console.error('Failed to update status');
})
.finally(() => this.saving = false);
}
}"
>
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<span class="flex flex-row gap-4 justify-between items-center">
{% for status_value, status_label in game_statuses %}
<template x-if="status == '{{ status_value }}'">
<c-gamestatus display="flex" status="{{ status_value }}">{{ status_label }}</c-gamestatus>
</template>
{% endfor %}
<c-icon.arrowdown />
</span>
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
{% for status_value, status_label in game_statuses %}
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
{% endfor %}
</ul>
</div>
</button>
</div>
</div>
-6
View File
@@ -1,6 +0,0 @@
<ul class="list-disc list-inside">
{% for change in statuschanges %}
<li class="text-slate-500">
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'games:edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'games:delete_statuschange' change.id %}">Delete</a>)</li>
{% endfor %}
</ul>
@@ -1,17 +0,0 @@
{% load modal_tag %}
{% python_modal "refund-confirmation-modal" %}
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h1>
<p class="dark:text-white text-center mt-5">
Are you sure you want to mark this purchase as refunded?
</p>
<form class="" hx-post="{% url 'games:refund_purchase' purchase_id %}" hx-target="#purchase-row-{{ purchase_id }}" hx-swap="outerHTML">
{% csrf_token %}
<p class="dark:text-white text-center mt-3 text-sm">
Games will be marked as abandoned.
</p>
<div class="items-center mt-5">
<c-button color="blue" size="lg" type="submit" class="w-full">Refund</c-button>
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#refund-confirmation-modal').remove()">Cancel</c-button>
</div>
</form>
{% endpython_modal %}
@@ -1 +0,0 @@
{{ form.related_purchase }}
@@ -1,49 +0,0 @@
<div class="flex gap-2 items-center"
x-data="{
originalDeviceId: {{ session.device.id|default:'null' }},
originalDeviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
deviceId: {{ session.device.id|default:'null' }},
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers(`/api/session/{{ session.id }}/device`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ device_id: newDeviceId })
})
.then((res) => {
document.body.dispatchEvent(new CustomEvent('device-changed'));
})
.catch(() => {
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
})
.finally(() => this.saving = false);
}
}"
>
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<span class="flex flex-row gap-4 justify-between items-center">
<span x-text="deviceName"></span>
<c-icon.arrowdown />
</span>
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
{% for device in session_devices %}
<li><a href="#" @click.prevent.stop="setDevice({{ device.id }}, '{{ device.name|escapejs }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': deviceId === {{ device.id }} }">{{ device.name }}</a></li>
{% endfor %}
</ul>
</div>
</button>
</div>
</div>
-18
View File
@@ -1,18 +0,0 @@
<c-layouts.base title="Login">
{% load static %}
<div class="flex items-center flex-col">
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
<form method="post">
<table>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Login" />
</td>
</tr>
</form>
</table>
</div>
</c-layouts.base>
-155
View File
@@ -1,155 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="flex self-center m-4 flex-col gap-4 [&_h1]:mb-2">
<div>
<h1 class="text-white text-lg">No size</h1>
<c-button>
No attributes
</c-button>
<c-button color="blue">
No attributes, blue
</c-button>
<c-button color="red">
No attributes, red
</c-button>
<c-button color="green">
No attributes, green
</c-button>
<c-button color="gray">
No attributes, gray
</c-button>
</div>
<div>
<h1 class="text-white text-lg">No size, icons</h1>
<c-button>
<c-icon.edit />
</c-button>
<c-button>
<c-icon.finish />
</c-button>
<c-button>
<c-icon.end />
</c-button>
<c-button>
<c-icon.delete />
</c-button>
<c-button>
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Extra Small, icons</h1>
<c-button icon="true" size="xs">
<c-icon.edit /> Edit
</c-button>
<c-button icon="true" size="xs">
<c-icon.finish />
</c-button>
<c-button icon="true" size="xs">
<c-icon.end />
</c-button>
<c-button icon="true" size="xs">
<c-icon.delete />
</c-button>
<c-button icon="true" size="xs">
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Small, icons</h1>
<c-button icon="true" size="sm">
<c-icon.edit /> Edit
</c-button>
<c-button icon="true" size="sm">
<c-icon.finish />
</c-button>
<c-button icon="true" size="sm">
<c-icon.end />
</c-button>
<c-button icon="true" size="sm">
<c-icon.delete />
</c-button>
<c-button icon="true" size="sm">
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Base, icons</h1>
<c-button icon="true" size="base">
<c-icon.edit /> Edit
</c-button>
<c-button icon="true" size="base">
<c-icon.finish />
</c-button>
<c-button icon="true" size="base">
<c-icon.end />
</c-button>
<c-button icon="true" size="base">
<c-icon.delete />
</c-button>
<c-button icon="true" size="base">
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Large, icons</h1>
<c-button icon="true" size="lg">
<c-icon.edit /> Edit
</c-button>
<c-button icon="true" size="lg">
<c-icon.finish />
</c-button>
<c-button icon="true" size="lg">
<c-icon.end />
</c-button>
<c-button icon="true" size="lg">
<c-icon.delete />
</c-button>
<c-button icon="true" size="lg">
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Extra Large, icons</h1>
<c-button icon="true" size="xl">
<c-icon.edit /> Edit
</c-button>
<c-button icon="true" size="xl">
<c-icon.finish />
</c-button>
<c-button icon="true" size="xl">
<c-icon.end />
</c-button>
<c-button icon="true" size="xl">
<c-icon.delete />
</c-button>
<c-button icon="true" size="xl">
<c-icon.play />
</c-button>
</div>
<div>
<h1 class="text-white text-lg">Group (sm)</h1>
<c-button-group>
<c-button-group-button-sm>
No attributes
</c-button-group-button-sm>
<c-button-group-button-sm color="blue">
No attributes, blue
</c-button-group-button-sm>
<c-button-group-button-sm color="red">
No attributes, red
</c-button-group-button-sm>
<c-button-group-button-sm color="green">
No attributes, green
</c-button-group-button-sm>
<c-button-group-button-sm color="gray">
No attributes, gray
</c-button-group-button-sm>
</c-button-group>
</div>
</div>
{% endblock content %}
-53
View File
@@ -1,53 +0,0 @@
{% load param_utils table_header_tag table_row_tag %}
<div class="shadow-md" hx-boost="false">
<div class="relative overflow-x-auto sm:rounded-t-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
{% if header_action %}
{% python_table_header slot=header_action %}
{% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">
{% for row in rows %}{% python_table_row data=row %}{% endfor %}
</tbody>
</table>
</div>
{% if page_obj and elided_page_range %}
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
aria-label="Table navigation">
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}
<a href="?{% param_replace page=page_obj.previous_page_number %}"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>
{% endif %}
{% for page in elided_page_range %}
<li>
{% if page != page_obj.number %}
<a href="?{% param_replace page=page %}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200">{{ page }}</a>
{% endif %}
</li>
{% endfor %}
{% if page_obj.has_next %}
<a href="?{% param_replace page=page_obj.next_page_number %}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>
{% endif %}
</li>
</ul>
</nav>
{% endif %}
</div>
-292
View File
@@ -1,292 +0,0 @@
<c-layouts.base>
{% load static %}
{% load duration_formatter %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_game.id>
{% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink>
{% else %}
{% if purchase.game_name %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
{% else %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %}
{% endif %}
{% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
{% if total_games %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_year_games }}</td>
</tr>
{% if all_finished_this_year_count %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
</tr>
{% if longest_session_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ longest_session_time }} (<c-gamelink :game_id=longest_session_game.id :name=longest_session_game.name />)
</td>
</tr>
{% endif %}
{% if highest_session_count_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_count }} (<c-gamelink :game_id=highest_session_count_game.id :name=highest_session_count_game.name />)
</td>
</tr>
{% endif %}
{% if highest_session_average_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_average }} (<c-gamelink :game_id=highest_session_average_game.id :name=highest_session_average_game.name />)
</td>
</tr>
{% endif %}
{% if first_play_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=first_play_game.id :name=first_play_game.name /> ({{ first_play_date }})
</td>
</tr>
{% endif %}
{% if last_play_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=last_play_game.id :name=last_play_game.name /> ({{ last_play_date }})
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if month_playtimes %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
{% for month in month_playtimes %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=game.id :name=game.name />
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.total_playtime | format_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.playtime | format_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if all_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_unfinished %}
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if all_purchased_this_year %}
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</c-layouts.base>
-173
View File
@@ -1,173 +0,0 @@
<c-layouts.base>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3">
<span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">
<c-popover id="popover-hours" popover_content="Total hours played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ game.playtime_formatted }}
</c-popover>
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
</svg>
{{ session_count }}
</c-popover>
<c-popover id="popover-average" popover_content="Average playtime per session" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
</svg>
{{ session_average_without_manual }}
</c-popover>
<c-popover id="popover-playrange" popover_content="Earliest and latest dates played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
{{ playrange }}
</c-popover>
</div>
<div class="flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4">
<div class="flex gap-2 items-center">
<span class="uppercase">Original year</span>
<span class="text-black dark:text-slate-300">{{ game.original_year_released }}</span>
</div>
<div class="flex gap-2 items-center"
>
<span class="uppercase">Status</span>
{% include "partials/gamestatus_selector.html" %}
{% if game.mastered %}👑{% endif %}
</div>
<div class="flex gap-2 items-center"
x-data="{ open: false }"
>
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
<a href="{% url 'games:add_playevent' %}">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<c-icon.arrowdown />
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="{% url 'games:add_playevent_for_game' game.id %}">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('{% url 'api-1.0.0:create_playevent' %}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/json' },
body: '{"game_id": {{ game.id }}}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>
<div class="flex gap-2 items-center">
<span class="uppercase">Platform</span>
<span class="text-black dark:text-slate-300">{{ game.platform }}</span>
</div>
</div>
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'games:edit_game' game.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Edit
</button>
</a>
<a href="#" hx-get="{% url 'games:delete_game_confirmation' game.id %}" hx-target="#global-modal-container">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Delete
</button>
</a>
</div>
</div>
<div class="mb-6">
<c-h1 :badge="purchase_count">Purchases</c-h1>
{% if purchase_count %}
{% include "simple_table.html" with rows=purchase_data.rows columns=purchase_data.columns page_obj=None elided_page_range=None header_action=None %}
{% else %}
No purchases yet.
{% endif %}
</div>
<div class="mb-6">
<c-h1 :badge="session_count">Sessions</c-h1>
{% if session_count %}
{% include "simple_table.html" with rows=session_data.rows columns=session_data.columns header_action=session_data.header_action page_obj=session_page_obj elided_page_range=session_elided_page_range %}
{% else %}
No sessions yet.
{% endif %}
</div>
<!-- list all playevents -->
<div class="mb-6">
<c-h1 :badge="playevent_count">Play Events</c-h1>
{% if playevent_count %}
{% include "simple_table.html" with rows=playevent_data.rows columns=playevent_data.columns page_obj=None elided_page_range=None header_action=None %}
{% else %}
No play events yet.
{% endif %}
</div>
<div class="mb-6" id="history-container" hx-get="" hx-trigger="status-changed from:body" hx-select="#history-container" hx-swap="outerHTML">
<c-h1 :badge="statuschange_count">History</c-h1>
{% include "partials/history.html" %}
</div>
</div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
</c-layouts.base>
-50
View File
@@ -1,50 +0,0 @@
<c-layouts.base>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex flex-col gap-5 mb-3">
<div class="font-bold font-serif text-slate-500 text-2xl">
{% if not purchase.name %}
Unnamed purchase
{% else %}
{{ purchase.name }}
{% endif %}
</div>
<span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif">
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
</span>
</span>
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'games:edit_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
Edit
</button>
</a>
<a href="{% url 'games:delete_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
Delete
</button>
</a>
</div>
<div>
<p>
Price:
<c-price-converted>{{ purchase.standardized_price }}</c-price-converted>
({{ purchase.price | floatformat:2 }} {{ purchase.price_currency }})
</p>
<p>Price per game: <c-price-converted>{{ purchase.price_per_game | floatformat:0 }} {{ purchase.converted_currency }}</c-price-converted> </p>
</div>
<div>
<h2 class="text-base">Items:</h2>
<ul class="list-disc list-inside">
{% for game in purchase.games.all %}
<li><c-gamelink :game_id=game.id :name=game.name /></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</c-layouts.base>
-21
View File
@@ -1,21 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import ButtonGroup
register = template.Library()
@register.simple_tag(takes_context=True)
def python_button_group(context, buttons=None):
"""Template tag that delegates button group rendering to ButtonGroup().
Supports two modes:
- buttons list passed: renders button links via ButtonGroup()
- no buttons (slot only): passes through children (showcase usage)
"""
if buttons is not None:
return ButtonGroup(buttons)
# Slot mode: render children directly (for <c-button-group> with direct children)
slot = context.get("slot", "")
return mark_safe(slot) if slot else ""
-51
View File
@@ -1,51 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import Button
register = template.Library()
@register.simple_tag
def python_button(
color: str = "blue",
size: str = "base",
icon: str = "",
type: str = "button",
class_: str = "",
hx_get: str = "",
hx_target: str = "",
hx_swap: str = "",
title: str = "",
onclick: str = "",
data_target: str = "",
data_type: str = "",
name: str = "",
slot: str = "",
) -> str:
"""Template tag that delegates to the Python Button() component."""
extra_attrs: list[tuple[str, str]] = []
if class_:
extra_attrs.append(("class", class_))
if data_target:
extra_attrs.append(("data-target", data_target))
if data_type:
extra_attrs.append(("data-type", data_type))
children = [mark_safe(slot)] if slot else []
return Button(
attributes=extra_attrs or None,
children=children or None,
size=size,
icon=icon if isinstance(icon, bool) else str(icon).lower() == "true",
color=color,
type=type,
hx_get=hx_get,
hx_target=hx_target,
hx_swap=hx_swap,
title=title,
onclick=onclick,
name=name,
)
-12
View File
@@ -1,12 +0,0 @@
from datetime import timedelta
from django import template
from common.time import durationformat, format_duration
register = template.Library()
@register.filter(name="format_duration")
def filter_format_duration(duration: timedelta, argument: str = durationformat):
return format_duration(duration, format_string=argument)
-12
View File
@@ -1,12 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import GameLink
register = template.Library()
@register.simple_tag
def python_gamelink(game_id: int, name: str = "", slot: str = "") -> str:
children = [mark_safe(slot)] if slot else []
return GameLink(game_id=game_id, name=name, children=children)
-12
View File
@@ -1,12 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import GameStatus
register = template.Library()
@register.simple_tag
def python_gamestatus(status: str = "u", display: str = "", class_: str = "", slot: str = "") -> str:
children = [mark_safe(slot)] if slot else []
return GameStatus(children=children, status=status, display=display, class_=class_)
-12
View File
@@ -1,12 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import H1
register = template.Library()
@register.simple_tag
def python_h1(badge: str = "", slot: str = "") -> str:
children = [mark_safe(slot)] if slot else []
return H1(children=children, badge=badge)
-10
View File
@@ -1,10 +0,0 @@
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name="markdown")
def markdown_format(text):
return mark_safe(markdown.markdown(text))
-33
View File
@@ -1,33 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import Modal
register = template.Library()
class ModalNode(template.Node):
def __init__(self, modal_id, nodelist):
self.modal_id = template.Variable(modal_id)
self.nodelist = nodelist
def render(self, context):
modal_id = self.modal_id.resolve(context)
content = self.nodelist.render(context)
return str(
Modal(modal_id=modal_id, children=[mark_safe(content)])
)
@register.tag("python_modal")
def do_modal(parser, token):
bits = token.split_contents()
tag_name = bits[0]
if len(bits) != 2:
raise template.TemplateSyntaxError(
f"{tag_name} requires exactly one argument: the modal ID"
)
modal_id = bits[1]
nodelist = parser.parse(("endpython_modal",))
parser.delete_first_token()
return ModalNode(modal_id, nodelist)
-18
View File
@@ -1,18 +0,0 @@
from typing import Any
from django import template
from django.http import QueryDict
register = template.Library()
@register.simple_tag(takes_context=True)
def param_replace(context: dict[Any, Any], **kwargs):
"""
Return encoded URL parameters that are the same as the current
request's parameters, only with the specified GET parameters added or changed.
"""
d: QueryDict = context["request"].GET.copy()
for k, v in kwargs.items():
d[k] = v
return d.urlencode()
-27
View File
@@ -1,27 +0,0 @@
from django import template
from common.components import _popover_html
register = template.Library()
@register.simple_tag
def python_popover(
popover_content: str = "",
wrapped_content: str = "",
wrapped_classes: str = "",
id: str = "",
slot: str = "",
) -> str:
"""Template tag that generates popover HTML natively.
Called from the cotton/popover.html shim template.
Delegates HTML generation to _popover_html().
"""
return _popover_html(
id=id,
popover_content=popover_content,
wrapped_content=wrapped_content,
wrapped_classes=wrapped_classes,
slot=slot,
)
-11
View File
@@ -1,11 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import PriceConverted
register = template.Library()
@register.simple_tag
def python_price_converted(slot: str = "") -> str:
return PriceConverted(children=[mark_safe(slot)] if slot else [])
-12
View File
@@ -1,12 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import TableHeader
register = template.Library()
@register.simple_tag
def python_table_header(slot: str = "") -> str:
children = [mark_safe(slot)] if slot else []
return TableHeader(children=children)
-10
View File
@@ -1,10 +0,0 @@
from django import template
from common.components import TableRow
register = template.Library()
@register.simple_tag
def python_table_row(data=None) -> str:
return TableRow(data=data)
-12
View File
@@ -1,12 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from common.components import TableTd
register = template.Library()
@register.simple_tag
def python_table_td(slot: str = "") -> str:
children = [mark_safe(slot)] if slot else []
return TableTd(children=children)
+19 -14
View File
@@ -1,10 +1,8 @@
from django.urls import path
app_name = "games"
from games.api import api
from games.views import (
device,
filter_presets,
game,
general,
platform,
@@ -14,6 +12,8 @@ from games.views import (
statuschange,
)
app_name = "games"
urlpatterns = [
path("", general.index, name="index"),
path("device/add", device.add_device, name="add_device"),
@@ -115,13 +115,11 @@ urlpatterns = [
path(
"session/add/from-game/<int:session_id>",
session.new_session_from_existing_session,
{"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"session/add/from-list/<int:session_id>",
session.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session",
),
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
@@ -133,41 +131,48 @@ urlpatterns = [
path(
"session/end/from-game/<int:session_id>",
session.end_session,
{"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
session.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
),
path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"),
path(
"statuschange/add",
statuschange.AddStatusChangeView.as_view(),
statuschange.add_statuschange,
name="add_statuschange",
),
path(
"statuschange/edit/<int:statuschange_id>",
statuschange.EditStatusChangeView.as_view(),
statuschange.edit_statuschange,
name="edit_statuschange",
),
path(
"statuschange/delete/<int:pk>",
statuschange.GameStatusChangeDeleteView.as_view(),
statuschange.delete_statuschange,
name="delete_statuschange",
),
path(
"statuschange/list",
statuschange.GameStatusChangeListView.as_view(),
statuschange.list_statuschanges,
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",
),
]
+57
View File
@@ -0,0 +1,57 @@
"""Authentication views rendered with the Python layout (replaces
registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe
from common.components import Component, CsrfInput, Div, Input
from common.layout import render_page
def _login_content(form, request) -> SafeText:
table = Component(
tag_name="table",
children=[
CsrfInput(request),
mark_safe(str(form.as_table())),
Component(
tag_name="tr",
children=[
Component(tag_name="td"),
Component(
tag_name="td",
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div(
[("class", "flex items-center flex-col")],
[
Component(
tag_name="h2",
attributes=[("class", "text-3xl text-white mb-8")],
children=["Please log in to continue"],
),
Component(
tag_name="form",
attributes=[("method", "post")],
children=[table],
),
],
)
class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python."""
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page(
self.request,
_login_content(context["form"], self.request),
title="Login",
)

Some files were not shown because too many files have changed in this diff Show More