Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d7988509cf
|
|||
|
d96066e625
|
|||
|
00f84fee9b
|
|||
|
2b43e9a848
|
|||
|
bf6d20ca58
|
|||
|
0a52c4da7b
|
@@ -4,7 +4,6 @@ __pycache__
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
@@ -12,4 +11,3 @@ dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
|
||||
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
|
||||
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
|
||||
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
|
||||
| Make migrations | `make makemigrations` |
|
||||
| Apply migrations | `make migrate` |
|
||||
| CSS (Tailwind) | `make css` |
|
||||
| Django shell | `make shell` |
|
||||
| Create superuser | `make createsuperuser` |
|
||||
| Format Python | `make format` (or `uv run ruff format`) |
|
||||
| Lint Python | `make lint` (or `uv run ruff check`) |
|
||||
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
|
||||
| Lint + format check + tests | `make check` (CI-style aggregate) |
|
||||
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
|
||||
| Load platform fixtures | `make loadplatforms` |
|
||||
| Load sample data | `make loadsample` |
|
||||
| Dump games data | `make dumpgames` |
|
||||
|
||||
## Architecture
|
||||
|
||||
A Django 6+ monolith (v1.7.0) with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a pure-Python server-side component system, plus a Django Ninja REST API.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
|
||||
### Models (in `games/models.py`)
|
||||
|
||||
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
||||
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase`
|
||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||
- **ExchangeRate** — cached FX rates per currency pair per year
|
||||
- **GameStatusChange** — audit log of status transitions, ordered by `-timestamp`
|
||||
- **FilterPreset** — saved filter configuration; `mode` (games/sessions/purchases/playevents), `find_filter`, `object_filter`, `ui_options` (all JSON). Follows Stash's SavedFilter pattern
|
||||
|
||||
**Sentinel objects**: `get_sentinel_platform()` returns an "Unspecified" platform used when a Game has no platform. A similar sentinel Device ("Unknown") is created when a Session has no device.
|
||||
|
||||
**GeneratedField constraint**: `duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish` are computed by the database and cannot be written from application code.
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
|
||||
|
||||
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
|
||||
|
||||
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
|
||||
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
|
||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||
|
||||
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
|
||||
|
||||
- `common/criteria.py` defines typed criterion classes: `StringCriterion`, `IntCriterion`, `FloatCriterion`, `DateCriterion`, `BoolCriterion`, `MultiCriterion`, `ChoiceCriterion`. Each has a `modifier` (`Modifier` enum: EQUALS, NOT_EQUALS, INCLUDES, EXCLUDES, GREATER_THAN, LESS_THAN, BETWEEN, IS_NULL, etc.) and a `to_q(field_name)` method.
|
||||
- `OperatorFilter` base class provides AND/OR/NOT sub-filter composition and JSON serialization/deserialization.
|
||||
- `games/filters.py` defines `GameFilter`, `SessionFilter`, `PurchaseFilter` (all `@dataclass` subclasses of `OperatorFilter`) and `FindFilter` (sort/pagination). Filters serialize to/from JSON and are passed in the `?filter=` query parameter.
|
||||
- `parse_game_filter()`, `parse_session_filter()`, `parse_purchase_filter()` helpers deserialize from a JSON string.
|
||||
- `FilterPreset` model stores named filter configurations that users can save and reload.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity:
|
||||
|
||||
- `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py` — CRUD for each entity
|
||||
- `general.py` — `stats()`, `stats_alltime()`, `index()`, `model_counts` context processor, `global_current_year` context processor, `use_custom_redirect` decorator (redirects to `request.session["return_path"]` if set)
|
||||
- `stats_data.py` — `compute_stats(year)` returns a `StatsData` TypedDict; pure computation, no HTTP
|
||||
- `stats_content.py` — renders stats page content from a `StatsData` dict
|
||||
- `filter_presets.py` — `list_presets`, `save_preset`, `delete_preset`, `load_preset`
|
||||
- `auth.py` — custom `LoginView` subclassing Django's auth view, renders login page via `render_page()`
|
||||
|
||||
**Signals** (`games/signals.py`):
|
||||
- `pre_save` on Purchase: snapshots old price/currency for change detection
|
||||
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
|
||||
- `m2m_changed` on Purchase.games: updates `num_purchases` count
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases (deletes Purchase if count reaches 0)
|
||||
- `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
|
||||
- `pre_save` on Game: creates `GameStatusChange` audit records when `status` changes
|
||||
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates from `cdn.jsdelivr.net/npm/@fawazahmed0/currency-api` and convert purchase prices to CZK.
|
||||
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present. Toast rendering is handled client-side by Alpine.js (`games/static/js/toast.js`).
|
||||
|
||||
**REST API** (`games/api.py`): Django Ninja with routers mounted at `/api/`:
|
||||
- `GET /api/games/search` — search games for autocomplete
|
||||
- `PATCH /api/games/{id}/status` — update game status
|
||||
- `GET/POST /api/playevent/` — list/create play events
|
||||
- `GET/PATCH/DELETE /api/playevent/{id}` — get/update/delete play event
|
||||
- `PATCH /api/session/{id}/device` — update session device
|
||||
|
||||
### Templates
|
||||
|
||||
Only a small number of HTML templates remain (platform icon snippets and partials). The bulk of the UI is built via Python components. Template files:
|
||||
|
||||
- `games/templates/icons/<slug>.html` — SVG icon snippets (loaded by `common/icons.py` via `get_icon()`)
|
||||
- `games/templates/` — minimal partials for HTMX responses where needed
|
||||
|
||||
### Frontend stack
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||
- **Custom JS** in `games/static/js/`:
|
||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `DEBUG` is `True` unless `PROD` env var is set
|
||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
|
||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
|
||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
|
||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pytest`. Key test files:
|
||||
|
||||
- `test_components.py` — component rendering
|
||||
- `test_filter_bars.py`, `test_filter_helpers.py`, `test_filters.py` — filter system
|
||||
- `test_paths_return_200.py` — smoke test all list/view URLs
|
||||
- `test_rendered_pages.py` — HTML output of pages
|
||||
- `test_signals.py` — signal side-effects (playtime recalc, status change audit, etc.)
|
||||
- `test_stats.py` — stats computation
|
||||
- `test_streak.py`, `test_time.py`, `test_session_formatting.py` — utilities
|
||||
- `test_middleware_integration.py`, `test_toast_middleware.py` — HTMX middleware
|
||||
- `test_price_update.py` — currency conversion signals
|
||||
- `test_search_select.py` — SearchSelect component
|
||||
|
||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
||||
|
||||
## Conventions for AI assistants
|
||||
|
||||
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
|
||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
||||
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
||||
@@ -1,14 +1,15 @@
|
||||
all: css migrate
|
||||
|
||||
initialize: npm css migrate loadplatforms
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find games/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
pnpm install
|
||||
npm install
|
||||
|
||||
css: common/input.css
|
||||
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
uv run python manage.py makemigrations
|
||||
@@ -17,17 +18,22 @@ migrate: makemigrations
|
||||
uv run python manage.py migrate
|
||||
|
||||
init:
|
||||
uv python install $(PYTHON_VERSION)
|
||||
uv install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
pnpm install
|
||||
npm install
|
||||
$(MAKE) sethookdir
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
sethookdir:
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/*
|
||||
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
@npx concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -67,23 +73,6 @@ uv.lock: pyproject.toml
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
test-e2e: uv.lock
|
||||
uv run pytest e2e/
|
||||
|
||||
lint:
|
||||
uv run ruff check
|
||||
|
||||
lint-fix:
|
||||
uv run ruff check --fix
|
||||
|
||||
format:
|
||||
uv run ruff format
|
||||
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ A simple game catalogue and play session tracker.
|
||||
|
||||
# Development
|
||||
|
||||
The project uses `uv` to manage Python versions and dependencies.
|
||||
Simply run:
|
||||
The project uses `pyenv` to manage installed Python versions.
|
||||
If you have `pyenv` installed, you can simply run:
|
||||
|
||||
```
|
||||
make init
|
||||
```
|
||||
|
||||
This installs the correct Python version, syncs all dependencies, and installs npm packages.
|
||||
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
|
||||
Afterwards, you can start the development server using `make dev`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,344 @@
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.utils import truncate
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
def _render_cached_impl(template: str, context_json: str) -> str:
|
||||
context = json.loads(context_json)
|
||||
context["slot"] = mark_safe(context["slot"])
|
||||
return render_to_string(template, context)
|
||||
|
||||
|
||||
if not settings.DEBUG:
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
else:
|
||||
_render_cached = _render_cached_impl
|
||||
|
||||
|
||||
def enable_cache():
|
||||
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
|
||||
global _render_cached
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
template: str = "",
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name and not template:
|
||||
raise ValueError("One of template or tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
childrenBlob = "\n".join(children)
|
||||
if len(attributes) == 0:
|
||||
attributesBlob = ""
|
||||
else:
|
||||
attributesList = [f'{name}="{value}"' for name, value in attributes]
|
||||
# make attribute list into a string
|
||||
# and insert space between tag and attribute list
|
||||
attributesBlob = f" {' '.join(attributesList)}"
|
||||
tag: str = ""
|
||||
if tag_name != "":
|
||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
||||
elif template != "":
|
||||
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
|
||||
tag = _render_cached(template, json.dumps(context, sort_keys=True))
|
||||
return mark_safe(tag)
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
|
||||
return seed + base
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> str:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
return Component(
|
||||
attributes=attributes
|
||||
+ [
|
||||
("id", id),
|
||||
("wrapped_content", wrapped_content),
|
||||
("popover_content", popover_content),
|
||||
("wrapped_classes", wrapped_classes),
|
||||
],
|
||||
children=children,
|
||||
template="cotton/popover.html",
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
template="cotton/button.html",
|
||||
attributes=attributes
|
||||
+ [
|
||||
("size", size),
|
||||
("icon", icon),
|
||||
("color", color),
|
||||
("class", "hover:cursor-pointer"),
|
||||
],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Form(
|
||||
action="",
|
||||
method="get",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=attributes + [("action", action), ("method", method)],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
try:
|
||||
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
|
||||
except TemplateDoesNotExist:
|
||||
result = Icon(name="unspecified", attributes=attributes)
|
||||
return result
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = purchase.platform.icon if game_count == 1 else "unspecified"
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Server-side HTML component library.
|
||||
|
||||
Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.utils import truncate
|
||||
|
||||
from common.components.core import (
|
||||
Component,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
_render_element,
|
||||
randomid,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
H1,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
Label,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Template,
|
||||
YearPicker,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
DEFAULT_PREFETCH,
|
||||
FilterSelect,
|
||||
LabeledOption,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
SessionDeviceSelector,
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"Component",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"Button",
|
||||
"ButtonGroup",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
"H1",
|
||||
"Icon",
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"SearchField",
|
||||
"DEFAULT_PREFETCH",
|
||||
"FilterSelect",
|
||||
"LabeledOption",
|
||||
"SearchSelect",
|
||||
"SearchSelectOption",
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"Label",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
"Template",
|
||||
"YearPicker",
|
||||
"paginated_table_content",
|
||||
"GameLink",
|
||||
"GameStatus",
|
||||
"GameStatusSelector",
|
||||
"LinkedPurchase",
|
||||
"NameWithIcon",
|
||||
"PriceConverted",
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
]
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Escaping core: the Component builder and its memoised renderer."""
|
||||
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
|
||||
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> str:
|
||||
"""Pure, memoized HTML builder behind `Component`.
|
||||
|
||||
Inputs are fully hashable and fully determine the output, so identical
|
||||
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||
(attribute values are always escaped). `children_key` is (child, is_safe)
|
||||
pairs: SafeText children pass through, plain strings are escaped. The
|
||||
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
|
||||
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
|
||||
render with the wrong escaping.
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
)
|
||||
if attrs_key:
|
||||
attributes_blob = " " + " ".join(
|
||||
f'{name}="{escape(value)}"' for name, value in attrs_key
|
||||
)
|
||||
else:
|
||||
attributes_blob = ""
|
||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
"""Render an HTML element. Attribute values are always escaped; children are
|
||||
escaped unless they are `SafeText` (so nested components pass through),
|
||||
preventing accidental HTML injection. Rendering is memoized via
|
||||
`_render_element`."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = (
|
||||
content_hash[:length]
|
||||
if not seed
|
||||
else content_hash[: max(0, length - len(seed))]
|
||||
)
|
||||
return seed + base
|
||||
@@ -1,342 +0,0 @@
|
||||
"""Domain components for games / purchases / sessions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component, HTMLTag
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
children = children or []
|
||||
display = children if children else [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", link),
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display if isinstance(display, list) else [display],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"u": "bg-gray-500",
|
||||
"p": "bg-orange-400",
|
||||
"f": "bg-green-500",
|
||||
"a": "bg-red-500",
|
||||
"r": "bg-purple-500",
|
||||
}
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> SafeText:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
||||
"gap-2 items-center align-middle"
|
||||
)
|
||||
if class_:
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = (
|
||||
(purchase.platform.icon if purchase.platform else "unspecified")
|
||||
if game_count == 1
|
||||
else "unspecified"
|
||||
)
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a game's status."""
|
||||
options_html = "\n".join(
|
||||
f"<template x-if=\"status == '{value}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||
f"</a></li>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<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;
|
||||
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);
|
||||
}}
|
||||
}}">
|
||||
{_dropdown_button_html(options_html, list_items)}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a session's device."""
|
||||
device_id = session.device_id or "null"
|
||||
device_name = (session.device.name if session.device else "Unknown").replace(
|
||||
"'", "\\'"
|
||||
)
|
||||
|
||||
list_items = "\n".join(
|
||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
||||
for d in session_devices
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
originalDeviceId: {device_id},
|
||||
originalDeviceName: '{device_name}',
|
||||
deviceId: {device_id},
|
||||
deviceName: '{device_name}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setDevice(newDeviceId, newDeviceName) {{
|
||||
this.deviceId = newDeviceId;
|
||||
this.deviceName = newDeviceName;
|
||||
this.saving = true;
|
||||
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);
|
||||
}}
|
||||
}}">
|
||||
{
|
||||
_dropdown_button_html(
|
||||
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
||||
)
|
||||
}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
||||
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
||||
return (
|
||||
'<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">'
|
||||
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</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">'
|
||||
f"{list_items}"
|
||||
"</ul>"
|
||||
"</div>"
|
||||
"</button>"
|
||||
"</div>"
|
||||
)
|
||||
@@ -1,936 +0,0 @@
|
||||
"""Stash-style filter bars, built from FilterSelect widgets."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.db import models
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component
|
||||
from common.components.primitives import Label, Span
|
||||
from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption
|
||||
|
||||
|
||||
class FilterChoice(NamedTuple):
|
||||
"""Parsed include/exclude/modifier state of a filter field from filter JSON.
|
||||
|
||||
``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For
|
||||
model-backed fields the label is embedded in the filter JSON (Stash-style);
|
||||
for enum fields the label is resolved from the fixed option list.
|
||||
"""
|
||||
|
||||
selected: list[LabeledOption]
|
||||
excluded: list[LabeledOption]
|
||||
modifier: str
|
||||
|
||||
|
||||
class RangeValues(NamedTuple):
|
||||
"""A (min, max) string pair parsed from a range filter criterion."""
|
||||
|
||||
min: str
|
||||
max: str
|
||||
|
||||
|
||||
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
||||
|
||||
|
||||
_FILTER_CHECKBOX_CLASS = (
|
||||
"rounded border-default-medium bg-neutral-secondary-medium "
|
||||
"text-brand focus:ring-brand"
|
||||
)
|
||||
|
||||
|
||||
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
||||
|
||||
|
||||
def _filter_parse(filter_json: str) -> dict:
|
||||
if not filter_json:
|
||||
return {}
|
||||
try:
|
||||
import json
|
||||
|
||||
loaded = json.loads(filter_json)
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
|
||||
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
|
||||
return [(str(item["id"]), str(item["label"])) for item in items]
|
||||
|
||||
|
||||
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
||||
raw = existing.get(field, {})
|
||||
if not isinstance(raw, dict):
|
||||
return FilterChoice([], [], "")
|
||||
return FilterChoice(
|
||||
selected=_extract_labeled(raw.get("value") or []),
|
||||
excluded=_extract_labeled(raw.get("excludes") or []),
|
||||
modifier=raw.get("modifier") or "",
|
||||
)
|
||||
|
||||
|
||||
def _parse_range(existing: dict, key: str) -> RangeValues:
|
||||
"""Extract (min, max) from a range filter criterion, defaulting to ("", "")."""
|
||||
field = existing.get(key, {})
|
||||
if not isinstance(field, dict):
|
||||
return RangeValues("", "")
|
||||
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
|
||||
|
||||
|
||||
def _parse_bool(existing: dict, key: str) -> bool:
|
||||
"""Extract a boolean value from a filter criterion."""
|
||||
field = existing.get(key, {})
|
||||
if not isinstance(field, dict):
|
||||
return False
|
||||
return bool(field.get("value", False))
|
||||
|
||||
|
||||
# ── FilterSelect adapters ────────────────────────────────────────────────────
|
||||
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
|
||||
# option set; model-backed fields fetch from a search endpoint on demand, with
|
||||
# labels embedded in the filter JSON so pills render without a DB round-trip.
|
||||
|
||||
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
|
||||
# mutually exclusive with value pills (selecting one clears the value set).
|
||||
# Must match JS PRESENCE_MODIFIERS in search_select.js.
|
||||
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
||||
|
||||
# M2M-only modifiers surfaced as additional pseudo-options in the dropdown.
|
||||
# "any" (INCLUDES) is the implicit default when neither a presence nor an
|
||||
# M2M modifier is set — no dedicated row needed. "none" (EXCLUDES) is
|
||||
# redundant with individual exclude (✗) pills. Only INCLUDES_ALL and
|
||||
# INCLUDES_ONLY can't be expressed through pills alone, so they are the
|
||||
# only M2M modifiers with explicit UI.
|
||||
_M2M_MODIFIERS: list[LabeledOption] = [
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
]
|
||||
|
||||
|
||||
def _modifier_options(
|
||||
nullable: bool, m2m_modifiers: list[LabeledOption] | None = None
|
||||
) -> list[LabeledOption]:
|
||||
"""Pinned pseudo-options rendered at the top of the dropdown.
|
||||
|
||||
Always includes ``(Any)`` (NOT_NULL); adds ``(None)`` (IS_NULL) when
|
||||
``nullable`` is True. When ``m2m_modifiers`` is given (M2M fields only),
|
||||
appends those rows (e.g. ``(All)`` / ``(Only)``)."""
|
||||
options: list[LabeledOption] = [("NOT_NULL", "(Any)")]
|
||||
if nullable:
|
||||
options.append(("IS_NULL", "(None)"))
|
||||
if m2m_modifiers:
|
||||
options.extend(m2m_modifiers)
|
||||
return options
|
||||
|
||||
|
||||
def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
||||
"""Return the modifier value to surface as the modifier pill.
|
||||
|
||||
Presence modifiers (NOT_NULL / IS_NULL) are always surfaced. Non-presence
|
||||
modifiers (INCLUDES / INCLUDES_ALL / INCLUDES_ONLY) only need a pill on M2M
|
||||
fields — otherwise the modifier is just the implicit default.
|
||||
"""
|
||||
if modifier in _PRESENCE_MODIFIERS or not has_m2m:
|
||||
return modifier
|
||||
if modifier:
|
||||
return modifier
|
||||
return ""
|
||||
|
||||
|
||||
def _enum_filter(
|
||||
field_name: str, options, choice: FilterChoice, *, nullable
|
||||
) -> SafeText:
|
||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
||||
|
||||
Enum fields are single-valued, so no M2M modifiers (all/only are
|
||||
meaningless); only the presence modifier is surfaced.
|
||||
"""
|
||||
options_str = [(str(value), label) for value, label in options]
|
||||
included = [
|
||||
(value, _find_label(options_str, value)) for value, _label in choice.selected
|
||||
]
|
||||
excluded = [
|
||||
(value, _find_label(options_str, value)) for value, _label in choice.excluded
|
||||
]
|
||||
modifier = _split_modifier(choice.modifier)
|
||||
return FilterSelect(
|
||||
field_name=field_name,
|
||||
options=options_str,
|
||||
included=included,
|
||||
excluded=excluded,
|
||||
modifier=modifier,
|
||||
modifier_options=_modifier_options(nullable),
|
||||
)
|
||||
|
||||
|
||||
def _model_filter(
|
||||
field_name: str,
|
||||
choice: FilterChoice,
|
||||
*,
|
||||
search_url,
|
||||
nullable,
|
||||
m2m_modifiers: list[LabeledOption] | None = None,
|
||||
) -> SafeText:
|
||||
"""A FilterSelect backed by a search endpoint.
|
||||
|
||||
Labels are embedded in the filter JSON (Stash-style), so pills render
|
||||
directly from ``choice`` with no DB round-trip. Pass ``m2m_modifiers`` for
|
||||
many-to-many fields to surface ``(All)`` / ``(Only)`` pseudo-options in the
|
||||
dropdown alongside the presence options.
|
||||
"""
|
||||
modifier = _split_modifier(choice.modifier, has_m2m=bool(m2m_modifiers))
|
||||
return FilterSelect(
|
||||
field_name=field_name,
|
||||
included=[(value, label or value) for value, label in choice.selected],
|
||||
excluded=[(value, label or value) for value, label in choice.excluded],
|
||||
modifier=modifier,
|
||||
modifier_options=_modifier_options(nullable, m2m_modifiers),
|
||||
search_url=search_url,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
)
|
||||
|
||||
|
||||
def _filter_mins_to_hrs(val) -> str:
|
||||
if val is None or val == "" or val == 0:
|
||||
return ""
|
||||
try:
|
||||
mins = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if mins == 0:
|
||||
return ""
|
||||
hrs = mins / 60
|
||||
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
|
||||
|
||||
|
||||
def _filter_field(label: str, widget) -> SafeText:
|
||||
"""A labelled filter field: <div><label>…</label>{widget}</div>."""
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
widget,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "checkbox"),
|
||||
("name", name),
|
||||
("value", "1"),
|
||||
*([("checked", "true")] if checked else []),
|
||||
("class", _FILTER_CHECKBOX_CLASS),
|
||||
],
|
||||
),
|
||||
label,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# SVG icons for the mode toggle (shared across all RangeSliders)
|
||||
_RANGE_ICON_SVG = (
|
||||
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||
'<line x1="3" y1="5" x2="13" y2="5" stroke="currentColor" stroke-width="1.5"/>'
|
||||
'<circle cx="3" cy="5" r="3" fill="currentColor"/>'
|
||||
'<circle cx="13" cy="5" r="3" fill="currentColor"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_POINT_ICON_SVG = (
|
||||
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||
'<circle cx="8" cy="5" r="3" fill="currentColor"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_RANGE_SLIDER_INPUT_CLASS = (
|
||||
"w-24 rounded-base border border-default-medium bg-neutral-secondary-medium "
|
||||
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
|
||||
)
|
||||
|
||||
|
||||
def RangeSlider(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
range_min: int,
|
||||
range_max: int,
|
||||
step: str = "1",
|
||||
min_placeholder: str = "",
|
||||
max_placeholder: str = "",
|
||||
) -> SafeText:
|
||||
"""A labelled range slider with number inputs and range/point mode toggle.
|
||||
|
||||
Renders a label row (label, two number inputs, toggle button) and a slider
|
||||
row (track with one or two custom draggable handles). Defaults to range mode
|
||||
(two handles). If min_value and max_value are both set and equal, starts in
|
||||
point mode (single handle). The toggle switches between modes.
|
||||
"""
|
||||
min_input_id = f"{input_name_prefix}-min"
|
||||
max_input_id = f"{input_name_prefix}-max"
|
||||
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||
initial_mode = "point" if point_mode else "range"
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "range-slider-block mb-4")],
|
||||
children=[
|
||||
# ── Label row ──
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[
|
||||
("class", _FILTER_LABEL_CLASS),
|
||||
("for", min_input_id),
|
||||
],
|
||||
children=[label],
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("placeholder", min_placeholder),
|
||||
(
|
||||
"class",
|
||||
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-dash text-body text-sm"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=["–"],
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("placeholder", max_placeholder),
|
||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"class",
|
||||
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||
"rounded cursor-pointer shrink-0",
|
||||
),
|
||||
(
|
||||
"title",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
(
|
||||
"aria-label",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-range"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-point"
|
||||
+ ("" if point_mode else " hidden"),
|
||||
),
|
||||
],
|
||||
children=[mark_safe(_POINT_ICON_SVG)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
# ── Slider row ──
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("class", "range-slider relative h-10 select-none mt-1"),
|
||||
("data-mode", initial_mode),
|
||||
("data-min", str(range_min)),
|
||||
("data-max", str(range_max)),
|
||||
("data-step", str(step)),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||
"rounded-full bg-neutral-quaternary",
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||
"h-2 bg-brand rounded-full",
|
||||
),
|
||||
("style", "left:0;width:100%"),
|
||||
],
|
||||
),
|
||||
# Min handle (hidden in point mode via JS)
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-min absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", min_input_id),
|
||||
(
|
||||
"style",
|
||||
"left:0" + (";display:none" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
# Max handle
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-max absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", max_input_id),
|
||||
("style", "left:100%"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_FILTER_FORM_ID = "filter-bar-form"
|
||||
|
||||
|
||||
_FILTER_INPUT_ID = "filter-json-input"
|
||||
|
||||
|
||||
def _filter_collapse_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"onclick",
|
||||
"var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
|
||||
),
|
||||
(
|
||||
"class",
|
||||
"flex items-center gap-2 text-sm font-medium text-body "
|
||||
"hover:text-heading mb-2",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
mark_safe(
|
||||
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'
|
||||
),
|
||||
"Filters",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex gap-3 items-center")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
"class",
|
||||
"px-4 py-2 text-sm font-medium text-white bg-brand "
|
||||
"rounded-lg hover:bg-brand-strong focus:ring-4 "
|
||||
"focus:ring-brand-medium",
|
||||
),
|
||||
],
|
||||
children=["Apply"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"onclick",
|
||||
f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')",
|
||||
),
|
||||
(
|
||||
"class",
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white "
|
||||
"border border-gray-200 rounded-lg hover:bg-gray-100 "
|
||||
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 "
|
||||
"dark:hover:bg-gray-700 dark:hover:text-white",
|
||||
),
|
||||
],
|
||||
children=["Clear"],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
("class", "flex gap-2 items-center"),
|
||||
("id", "save-preset-area"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("id", "preset-name-input"),
|
||||
("placeholder", "Preset name..."),
|
||||
(
|
||||
"class",
|
||||
"hidden px-3 py-2 text-sm rounded-lg border "
|
||||
"border-default-medium bg-neutral-secondary-medium "
|
||||
"text-heading focus:ring-brand focus:border-brand",
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("id", "save-preset-btn"),
|
||||
("onclick", "showPresetNameInput()"),
|
||||
(
|
||||
"class",
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 "
|
||||
"bg-white border border-gray-200 rounded-lg "
|
||||
"hover:bg-gray-100 dark:bg-gray-800 "
|
||||
"dark:border-gray-600 dark:text-gray-400 "
|
||||
"dark:hover:bg-gray-700 dark:hover:text-white",
|
||||
),
|
||||
],
|
||||
children=["Save Preset"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("id", "confirm-save-preset-btn"),
|
||||
(
|
||||
"onclick",
|
||||
f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')",
|
||||
),
|
||||
(
|
||||
"class",
|
||||
"hidden px-4 py-2 text-sm font-medium text-white "
|
||||
"bg-green-700 rounded-lg hover:bg-green-800 "
|
||||
"focus:ring-4 focus:ring-green-300",
|
||||
),
|
||||
],
|
||||
children=["Save"],
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("id", "preset-dropdown"),
|
||||
("class", "relative"),
|
||||
("data-preset-list-url", preset_list_url),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", "text-sm text-body")],
|
||||
children=["Loading presets..."],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText:
|
||||
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body
|
||||
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
|
||||
the hidden filter-json input and the Apply/Clear/preset action row."""
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||
children=[
|
||||
_filter_collapse_button(),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("id", "filter-bar-body"),
|
||||
(
|
||||
"class",
|
||||
"hidden border border-default-medium rounded-base p-4 "
|
||||
"bg-neutral-secondary-medium/50",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="form",
|
||||
attributes=[
|
||||
("id", _FILTER_FORM_ID),
|
||||
("onsubmit", "return applyFilterBar(event)"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "hidden"),
|
||||
("id", _FILTER_INPUT_ID),
|
||||
("name", "filter"),
|
||||
# NB: Component escapes attribute values, so the
|
||||
# raw JSON is passed through (no double-escape).
|
||||
("value", filter_json),
|
||||
],
|
||||
),
|
||||
*fields,
|
||||
_filter_action_row(preset_list_url, preset_save_url),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def FilterBar(
|
||||
filter_json: str = "",
|
||||
status_options: list[LabeledOption] | None = None,
|
||||
preset_list_url: str = "",
|
||||
preset_save_url: str = "",
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Game list."""
|
||||
from games.models import Game
|
||||
|
||||
if status_options is None:
|
||||
status_options = [(s.value, s.label) for s in Game.Status]
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
status_choice = _filter_get_choice(existing, "status")
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
|
||||
year_min, year_max = _parse_range(existing, "year_released")
|
||||
mastered_value = _parse_bool(existing, "mastered")
|
||||
playtime = existing.get("playtime_minutes", {})
|
||||
if isinstance(playtime, dict):
|
||||
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
||||
playtime_max = _filter_mins_to_hrs(playtime.get("value2", ""))
|
||||
else:
|
||||
playtime_min = ""
|
||||
playtime_max = ""
|
||||
|
||||
try:
|
||||
year_aggregate = Game.objects.aggregate(
|
||||
year_min=models.Min("year_released"), year_max=models.Max("year_released")
|
||||
)
|
||||
except Exception:
|
||||
year_aggregate = {}
|
||||
try:
|
||||
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
||||
except Exception:
|
||||
playtime_aggregate = {}
|
||||
year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
|
||||
year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
|
||||
playtime_range_max = (
|
||||
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
||||
if playtime_aggregate.get("playtime_max")
|
||||
else 200
|
||||
)
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Status",
|
||||
_enum_filter(
|
||||
"status",
|
||||
status_options,
|
||||
status_choice,
|
||||
nullable=not Game._meta.get_field("status").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform",
|
||||
_model_filter(
|
||||
"platform",
|
||||
platform_choice,
|
||||
search_url="/api/platforms/search",
|
||||
nullable=Game._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Year",
|
||||
input_name_prefix="filter-year",
|
||||
min_value=year_min,
|
||||
max_value=year_max,
|
||||
range_min=year_range_min,
|
||||
range_max=year_range_max,
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Playtime",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
range_min=0,
|
||||
range_max=playtime_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def _find_label(options: list[LabeledOption], value: str) -> str:
|
||||
for v, label in options:
|
||||
if str(v) == str(value):
|
||||
return label
|
||||
return value
|
||||
|
||||
|
||||
def SessionFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Session list."""
|
||||
from games.models import Game, Session
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
device_choice = _filter_get_choice(existing, "device")
|
||||
|
||||
duration_min, duration_max = _parse_range(existing, "duration_minutes")
|
||||
duration_min = _filter_mins_to_hrs(duration_min)
|
||||
duration_max = _filter_mins_to_hrs(duration_max)
|
||||
emulated_value = _parse_bool(existing, "emulated")
|
||||
is_active_value = _parse_bool(existing, "is_active")
|
||||
try:
|
||||
duration_aggregate = Session.objects.aggregate(
|
||||
duration_max=models.Max("duration_total")
|
||||
)
|
||||
duration_range_max = max(
|
||||
int((duration_aggregate.get("duration_max") or 0).total_seconds() / 3600)
|
||||
if duration_aggregate.get("duration_max")
|
||||
else 200,
|
||||
1,
|
||||
)
|
||||
except Exception:
|
||||
duration_range_max = 200
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Game",
|
||||
_model_filter(
|
||||
"game",
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=not Game._meta.get_field("name").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Device",
|
||||
_model_filter(
|
||||
"device",
|
||||
device_choice,
|
||||
search_url="/api/devices/search",
|
||||
nullable=Session._meta.get_field("device").null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Duration",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=duration_min,
|
||||
max_value=duration_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max,
|
||||
min_placeholder="e.g. 0.5",
|
||||
max_placeholder="e.g. 10",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-emulated", "Emulated", emulated_value),
|
||||
_filter_checkbox("filter-active", "Active", is_active_value),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PurchaseFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Purchase list."""
|
||||
from games.models import Purchase
|
||||
|
||||
type_options = Purchase.TYPES
|
||||
ownership_options = Purchase.OWNERSHIP_TYPES
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "games")
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
type_choice = _filter_get_choice(existing, "type")
|
||||
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
||||
price_min, price_max = _parse_range(existing, "price")
|
||||
is_refunded_value = _parse_bool(existing, "is_refunded")
|
||||
try:
|
||||
price_aggregate = Purchase.objects.aggregate(
|
||||
price_min=models.Min("price"), price_max=models.Max("price")
|
||||
)
|
||||
price_range_min = int(price_aggregate.get("price_min") or 0)
|
||||
price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
|
||||
except Exception:
|
||||
price_range_min, price_range_max = 0, 100
|
||||
|
||||
num_min, num_max = _parse_range(existing, "num_purchases")
|
||||
try:
|
||||
num_aggregate = Purchase.objects.aggregate(
|
||||
num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases")
|
||||
)
|
||||
num_range_min = max(int(num_aggregate.get("num_min") or 0), 0)
|
||||
num_range_max = max(int(num_aggregate.get("num_max") or 10), 1)
|
||||
except Exception:
|
||||
num_range_min, num_range_max = 0, 10
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Game",
|
||||
_model_filter(
|
||||
"games",
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=False,
|
||||
# games is many-to-many on Purchase: (All) means
|
||||
# INCLUDES_ALL ("purchase linked to every selected
|
||||
# game"); (Only) means INCLUDES_ONLY.
|
||||
m2m_modifiers=_M2M_MODIFIERS,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform",
|
||||
_model_filter(
|
||||
"platform",
|
||||
platform_choice,
|
||||
search_url="/api/platforms/search",
|
||||
nullable=Purchase._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Type",
|
||||
_enum_filter(
|
||||
"type",
|
||||
type_options,
|
||||
type_choice,
|
||||
nullable=not Purchase._meta.get_field("type").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Ownership",
|
||||
_enum_filter(
|
||||
"ownership_type",
|
||||
ownership_options,
|
||||
ownership_choice,
|
||||
nullable=not Purchase._meta.get_field(
|
||||
"ownership_type"
|
||||
).has_default(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Price",
|
||||
input_name_prefix="filter-price",
|
||||
min_value=price_min,
|
||||
max_value=price_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0.00",
|
||||
max_placeholder="100.00",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Games in purchase",
|
||||
input_name_prefix="filter-num-purchases",
|
||||
min_value=num_min,
|
||||
max_value=num_max,
|
||||
range_min=num_range_min,
|
||||
range_max=num_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
@@ -1,954 +0,0 @@
|
||||
"""Generic HTML primitives (no domain knowledge)."""
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
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 common.icons import get_icon
|
||||
from common.utils import truncate
|
||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||
|
||||
|
||||
_COLOR_CLASSES = {
|
||||
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
||||
"red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white",
|
||||
"gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border",
|
||||
"green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white",
|
||||
}
|
||||
|
||||
|
||||
_SIZE_CLASSES = {
|
||||
"xs": "px-3 py-2 text-xs shadow-xs",
|
||||
"sm": "px-3 py-2 text-sm",
|
||||
"base": "px-5 py-2.5 text-sm",
|
||||
"lg": "px-5 py-3 text-base",
|
||||
"xl": "px-6 py-3.5 text-base",
|
||||
}
|
||||
|
||||
|
||||
def _popover_html(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate popover HTML using Component(tag_name=...).
|
||||
|
||||
Single source of truth for popover HTML structure.
|
||||
Used by Popover() and the python_popover template tag bridge.
|
||||
"""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Span(
|
||||
attributes=[
|
||||
("data-popover-target", id),
|
||||
("class", wrapped_classes),
|
||||
],
|
||||
children=[display_content] if display_content else [],
|
||||
)
|
||||
|
||||
popover_tooltip_class = (
|
||||
"absolute z-10 invisible inline-block text-sm text-white "
|
||||
"transition-opacity duration-300 bg-white border border-purple-200 "
|
||||
"rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 "
|
||||
"dark:bg-purple-800"
|
||||
)
|
||||
|
||||
div = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-popover", ""),
|
||||
("id", id),
|
||||
("role", "tooltip"),
|
||||
("class", popover_tooltip_class),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "px-3 py-2")],
|
||||
children=[popover_content],
|
||||
),
|
||||
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
|
||||
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||
"from Python component -->"
|
||||
),
|
||||
Span(
|
||||
attributes=[("class", "hidden decoration-dotted")],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
|
||||
slot = mark_safe("\n".join(children))
|
||||
return _popover_html(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
wrapped_content=wrapped_content,
|
||||
wrapped_classes=wrapped_classes,
|
||||
slot=slot,
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
type: str = "button",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
hx_swap: str = "",
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
custom_class = ""
|
||||
other_attrs: list[HTMLAttribute] = []
|
||||
for attr_name, attr_value in attributes:
|
||||
if attr_name == "class":
|
||||
custom_class = str(attr_value)
|
||||
else:
|
||||
other_attrs.append((attr_name, attr_value))
|
||||
|
||||
# Build class string: custom class first, then base, color, size, icon
|
||||
class_parts: list[str] = []
|
||||
if custom_class:
|
||||
class_parts.append(custom_class)
|
||||
class_parts.append(
|
||||
"hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 "
|
||||
"font-medium mb-2 me-2 rounded-base"
|
||||
)
|
||||
class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"]))
|
||||
class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"]))
|
||||
if icon:
|
||||
class_parts.append("inline-flex text-center items-center gap-2")
|
||||
|
||||
# Build the full attribute list for the button tag
|
||||
button_attrs: list[HTMLAttribute] = [
|
||||
("type", type),
|
||||
("class", " ".join(class_parts)),
|
||||
]
|
||||
if hx_get:
|
||||
button_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
button_attrs.append(("hx-target", hx_target))
|
||||
if hx_swap:
|
||||
button_attrs.append(("hx-swap", hx_swap))
|
||||
if title:
|
||||
button_attrs.append(("title", title))
|
||||
if onclick:
|
||||
button_attrs.append(("onclick", onclick))
|
||||
if name:
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
_GROUP_BUTTON_COLORS = {
|
||||
"gray": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"red": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:border-red-700 "
|
||||
"dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"green": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-green-500 hover:border-green-600 "
|
||||
"hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 "
|
||||
"focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:text-white dark:hover:border-green-700 "
|
||||
"dark:hover:bg-green-600 dark:focus:ring-green-500 "
|
||||
"dark:focus:text-white"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _button_group_button(
|
||||
href: str,
|
||||
slot: str,
|
||||
color: str = "gray",
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
a_attrs: list[HTMLAttribute] = [("href", href)]
|
||||
if hx_get:
|
||||
a_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
a_attrs.append(("hx-target", hx_target))
|
||||
a_attrs.append(
|
||||
(
|
||||
"class",
|
||||
"[&:first-of-type_button]:rounded-s-lg "
|
||||
"[&:last-of-type_button]:rounded-e-lg",
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
("class", color_classes + " hover:cursor-pointer"),
|
||||
],
|
||||
children=[slot],
|
||||
)
|
||||
|
||||
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||
|
||||
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
"""Generate a button group div.
|
||||
|
||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||
Empty dicts (no slot) are silently skipped — matching the template behavior
|
||||
for conditional buttons (e.g., end-session only when session is active).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
continue
|
||||
children.append(
|
||||
_button_group_button(
|
||||
href=btn.get("href", "#"),
|
||||
slot=btn["slot"],
|
||||
color=btn.get("color", "gray"),
|
||||
title=btn.get("title", ""),
|
||||
hx_get=btn.get("hx_get", ""),
|
||||
hx_target=btn.get("hx_target", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Span(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="span", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Template(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="template", attributes=attributes, children=children)
|
||||
|
||||
|
||||
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
||||
# input.css, written inline so styling stays encapsulated in the component). The
|
||||
# JS that builds pills client-side (search_select.js) MUST emit these exact class
|
||||
# strings byte-for-byte so Tailwind generates them and server/JS pills match.
|
||||
_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
|
||||
|
||||
def Pill(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
label_slot: bool = False,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
||||
``data-value``; extra ``attributes`` are appended to the outer span.
|
||||
|
||||
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
|
||||
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||
markup single-sourced — see ``search_select.py``).
|
||||
"""
|
||||
attributes = attributes or []
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
label_child: HTMLTag = (
|
||||
Span(attributes=[("data-search-select-label", "")], children=[label])
|
||||
if label_slot
|
||||
else label
|
||||
)
|
||||
children: list[HTMLTag] = [label_child]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
)
|
||||
|
||||
return Span(attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||
)
|
||||
|
||||
|
||||
def ModuleScript(filename: str) -> SafeText:
|
||||
"""A `<script type="module">` tag pointing at a static JS file."""
|
||||
return mark_safe(
|
||||
f'<script type="module" src="{static("js/" + filename)}"></script>'
|
||||
)
|
||||
|
||||
|
||||
def ExternalScript(url: str) -> SafeText:
|
||||
"""A plain `<script src=...>` tag for an external/CDN script."""
|
||||
return mark_safe(f'<script src="{url}"></script>')
|
||||
|
||||
|
||||
def YearPicker(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||
state). `available_years` are the years to enable in the popup grid.
|
||||
`url_template` is a navigation URL containing the literal ``__year__``
|
||||
placeholder, substituted with the chosen year in JS (keeps this component
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
selected = str(year) if year is not None else ""
|
||||
classes = (
|
||||
"bg-brand text-white border-transparent hover:bg-brand-strong"
|
||||
if year is not None
|
||||
else "bg-neutral-secondary-medium text-heading border border-default-medium "
|
||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||
)
|
||||
years_csv = ",".join(str(y) for y in available_years)
|
||||
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
@keydown.escape.window="pickerOpen = false">
|
||||
<button type="button"
|
||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||
class="inline-flex items-center rounded-base px-4 py-2 text-sm font-medium {classes}">
|
||||
{label}
|
||||
<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="text" x-ref="pickerInput" id="year-picker-input"
|
||||
class="absolute opacity-0 pointer-events-none"
|
||||
style="width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
|
||||
data-available-years="{years_csv}"
|
||||
data-selected-year="{selected}"
|
||||
data-url-template="{url_template}">
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
const pickerEl = document.getElementById('year-picker-input');
|
||||
if (!pickerEl || pickerEl._pickerInstance) return;
|
||||
|
||||
const selectedYear = pickerEl.dataset.selectedYear;
|
||||
const urlTemplate = pickerEl.dataset.urlTemplate;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = new Set(pickerEl.dataset.availableYears
|
||||
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n)));
|
||||
|
||||
const picker = new Datepicker(pickerEl, {{
|
||||
pickLevel: 2,
|
||||
format: 'yyyy',
|
||||
minDate: new Date(1999, 0, 1),
|
||||
maxDate: new Date(currentYear, 11, 31),
|
||||
autohide: false,
|
||||
orientation: 'bottom end',
|
||||
showOnClick: false,
|
||||
showOnFocus: false,
|
||||
beforeShowYear: (date) => ({{ enabled: availableYears.has(date.getFullYear()) }})
|
||||
}});
|
||||
pickerEl._pickerInstance = picker;
|
||||
|
||||
picker.element.addEventListener('changeDate', (e) => {{
|
||||
const year = e.detail.date?.getFullYear();
|
||||
if (year && urlTemplate) {{
|
||||
window.location.href = urlTemplate.replace('__year__', year);
|
||||
}}
|
||||
}});
|
||||
|
||||
if (selectedYear) {{
|
||||
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
|
||||
picker.update();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
|
||||
|
||||
def AddForm(
|
||||
form,
|
||||
*,
|
||||
request,
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||
|
||||
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||
session form, which lays out its fields manually). `additional_row` holds
|
||||
extra submit buttons rendered below the main Submit button. `submit_class`
|
||||
is applied to the main Submit button (the session form passes "" to match
|
||||
its original markup).
|
||||
"""
|
||||
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
field_markup,
|
||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||
Div(
|
||||
[("class", "submit-button-container")],
|
||||
[additional_row] if additional_row else [],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return Div(
|
||||
[("id", "add-form"), ("class", "max-width-container")],
|
||||
[
|
||||
Div(
|
||||
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
|
||||
[inner_form],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[
|
||||
("for", "search"),
|
||||
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "relative")],
|
||||
children=[
|
||||
mark_safe(
|
||||
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
|
||||
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
|
||||
'fill="none" viewBox="0 0 24 24">'
|
||||
'<path stroke="currentColor" stroke-linecap="round" stroke-width="2" '
|
||||
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
||||
"</svg></div>"
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "search"),
|
||||
("id", id),
|
||||
("name", id),
|
||||
("value", search_string),
|
||||
(
|
||||
"class",
|
||||
"block w-full p-3 ps-9 bg-neutral-secondary-medium "
|
||||
"border border-default-medium text-heading text-sm "
|
||||
"rounded-base focus:ring-brand focus:border-brand "
|
||||
"shadow-xs placeholder:text-body",
|
||||
),
|
||||
("placeholder", placeholder),
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
"class",
|
||||
"absolute end-1.5 bottom-1.5 text-white bg-brand "
|
||||
"hover:bg-brand-strong box-border border border-transparent "
|
||||
"focus:ring-4 focus:ring-brand-medium shadow-xs font-medium "
|
||||
"leading-5 rounded text-xs px-3 py-1.5 focus:outline-none "
|
||||
"cursor-pointer",
|
||||
),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||
badge_html = ""
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
badge_html = Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"bg-blue-100 text-blue-800 text-2xl font-semibold me-2 "
|
||||
"px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2",
|
||||
),
|
||||
],
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
)
|
||||
|
||||
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("id", modal_id),
|
||||
(
|
||||
"class",
|
||||
"fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto "
|
||||
"h-full w-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"relative mx-auto p-5 border-accent border w-full max-w-md "
|
||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=(children if isinstance(children, list) else [children]),
|
||||
),
|
||||
],
|
||||
)
|
||||
return mark_safe(str(outer))
|
||||
|
||||
|
||||
def TableTd(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="td",
|
||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
"""Generate a <tr> from a row data dict or list.
|
||||
|
||||
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
||||
- first cell is <th>, rest <td>.
|
||||
List form: [...] — all cells are <td>.
|
||||
"""
|
||||
if data is None:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
row_id = data.get("row_id", "")
|
||||
cells = data.get("cell_data", [])
|
||||
else:
|
||||
row_id = ""
|
||||
cells = data
|
||||
|
||||
tr_class = (
|
||||
"odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 "
|
||||
"dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 "
|
||||
"dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 "
|
||||
"[&_a]:decoration-2 [&_td:last-child]:text-right"
|
||||
)
|
||||
tr_attrs: list[HTMLAttribute] = [("class", tr_class)]
|
||||
if row_id:
|
||||
tr_attrs.append(("id", row_id))
|
||||
if isinstance(data, dict):
|
||||
if data.get("hx_trigger"):
|
||||
tr_attrs.append(("hx-trigger", data["hx_trigger"]))
|
||||
if data.get("hx_get"):
|
||||
tr_attrs.append(("hx-get", data["hx_get"]))
|
||||
if data.get("hx_select"):
|
||||
tr_attrs.append(("hx-select", data["hx_select"]))
|
||||
if data.get("hx_swap"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
Component(
|
||||
tag_name="th",
|
||||
attributes=[
|
||||
("scope", "row"),
|
||||
(
|
||||
"class",
|
||||
"px-6 py-4 font-medium text-gray-900 "
|
||||
"whitespace-nowrap dark:text-white",
|
||||
),
|
||||
],
|
||||
children=[cell],
|
||||
)
|
||||
)
|
||||
else:
|
||||
cell_elements.append(TableTd(children=[cell]))
|
||||
|
||||
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"p-2 text-lg font-semibold rtl:text-left text-right "
|
||||
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def _page_url(request, page) -> str:
|
||||
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
|
||||
if request is None:
|
||||
return f"?page={page}"
|
||||
params = request.GET.copy()
|
||||
params["page"] = page
|
||||
return "?" + params.urlencode()
|
||||
|
||||
|
||||
def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
||||
pages_html = ""
|
||||
for page in elided_page_range:
|
||||
if page != page_obj.number:
|
||||
pages_html += (
|
||||
f'<li><a href="{_page_url(request, 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 "
|
||||
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
else:
|
||||
pages_html += (
|
||||
'<li><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 "
|
||||
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
|
||||
if page_obj.has_previous():
|
||||
prev_html = (
|
||||
f'<a href="{_page_url(request, 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:
|
||||
prev_html = (
|
||||
'<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>'
|
||||
)
|
||||
|
||||
if page_obj.has_next():
|
||||
next_html = (
|
||||
f'<a href="{_page_url(request, 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:
|
||||
next_html = (
|
||||
'<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>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<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">'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
|
||||
f'<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>'
|
||||
f"{prev_html}{pages_html}{next_html}"
|
||||
"</li></ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
def SimpleTable(
|
||||
columns: list[str] | None = None,
|
||||
rows: list | None = None,
|
||||
header_action: SafeText | str | None = None,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
|
||||
header_html = ""
|
||||
if header_action:
|
||||
header_html = str(TableHeader(children=[header_action]))
|
||||
|
||||
columns_html = "".join(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
for col in columns
|
||||
)
|
||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||
|
||||
pagination_html = ""
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
'<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">'
|
||||
f"{header_html}"
|
||||
'<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">'
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
)
|
||||
|
||||
|
||||
def paginated_table_content(
|
||||
data: dict,
|
||||
*,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
``header_action`` (the same shape every list view already builds).
|
||||
"""
|
||||
return Div(
|
||||
[
|
||||
(
|
||||
"class",
|
||||
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
|
||||
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
|
||||
)
|
||||
],
|
||||
[
|
||||
SimpleTable(
|
||||
columns=data["columns"],
|
||||
rows=data["rows"],
|
||||
header_action=data["header_action"],
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -1,566 +0,0 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
||||
|
||||
This module imports only from ``common.components`` — it has no Django-forms or
|
||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||
|
||||
Option sourcing follows two axes. *Population*: options are either rendered
|
||||
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
|
||||
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
|
||||
filtering is purely client-side; with a ``search_url`` the loaded rows are a
|
||||
window, so the JS filters the loaded rows instantly on each keystroke while
|
||||
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
|
||||
first open, ``0`` = none) seeds that window so the panel is populated before the
|
||||
user types.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components.core import Component, HTMLAttribute
|
||||
from common.components.primitives import Div, Input, Pill, Span, Template
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||
|
||||
|
||||
# A lightweight (value, label) pair used wherever only those two fields are
|
||||
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
|
||||
# SearchSelectOption adds a ``data`` dict for extra row attributes.
|
||||
LabeledOption = tuple[str, str]
|
||||
|
||||
|
||||
# The pills and the search box share one flex-wrap row (with padding) so the
|
||||
# widget reads as a single clickable field; the pills wrapper uses `contents`
|
||||
# so its pills/hidden inputs flow as direct participants of that row, inline
|
||||
# with the search input. The options panel is absolute, so it sits outside the
|
||||
# flex flow. (border omitted intentionally — see if it's needed later.)
|
||||
_CONTAINER_CLASS = (
|
||||
"relative flex flex-wrap items-center gap-1 p-2 "
|
||||
"rounded-base bg-neutral-secondary-medium"
|
||||
)
|
||||
_PILLS_CLASS = "contents"
|
||||
_SEARCH_CLASS = (
|
||||
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
|
||||
"focus:ring-0 focus:outline-hidden placeholder:text-body"
|
||||
)
|
||||
# top-full anchors the panel to the container's bottom edge: as an absolutely
|
||||
# positioned child of the flex field, its static position would otherwise be
|
||||
# centered by items-center and overlap the search box.
|
||||
_OPTIONS_CLASS = (
|
||||
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
|
||||
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = (
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer "
|
||||
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
|
||||
)
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
# Default number of rows to fetch on first focus when a search_url is set.
|
||||
# Shared by filter and form widgets so the dropdown is populated for keyboard
|
||||
# navigation as soon as the user opens it.
|
||||
DEFAULT_PREFETCH = 20
|
||||
|
||||
# ── FilterSelect styling ───────────────────────────────────────────────────
|
||||
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
|
||||
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
|
||||
# rows/pills are cloned from server-rendered <template>s, so these strings live
|
||||
# only here — never duplicated in search_select.js. The keyboard-highlighted
|
||||
# state is expressed via Tailwind `data-[search-select-highlighted]` and
|
||||
# `group-data-[search-select-highlighted]` variants on the row/label/button
|
||||
# classes below; the JS only toggles the data attribute on the row.
|
||||
_FILTER_INCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_FILTER_EXCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-red-500/15 text-red-600 line-through decoration-red-400"
|
||||
)
|
||||
_FILTER_MODIFIER_PILL_CLASS = (
|
||||
"inline-flex items-center px-2 py-0.5 text-sm rounded "
|
||||
"bg-amber-500/15 text-amber-600 cursor-pointer"
|
||||
)
|
||||
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
_FILTER_OPTION_ROW_CLASS = (
|
||||
"group flex items-center justify-between px-2 py-1 rounded text-sm "
|
||||
"hover:bg-neutral-secondary-strong cursor-pointer "
|
||||
"data-[search-select-highlighted]:bg-brand "
|
||||
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
|
||||
"data-[search-select-highlighted]:outline-brand-strong"
|
||||
)
|
||||
_FILTER_OPTION_LABEL_CLASS = (
|
||||
"truncate text-body group-data-[search-select-highlighted]:text-white"
|
||||
)
|
||||
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
|
||||
# text-body keeps the +/− readable on dark backgrounds; hover:border-brand-strong
|
||||
# keeps the edge visible against the brand hover fill. When the row is the
|
||||
# keyboard-highlighted one its bg is brand, so the button text/border switch
|
||||
# to white and the hover fill shifts to brand-strong for contrast.
|
||||
_FILTER_ACTION_BUTTON_CLASS = (
|
||||
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
|
||||
"border border-brand "
|
||||
"hover:bg-brand hover:text-white hover:border-brand-strong "
|
||||
"group-data-[search-select-highlighted]:text-white "
|
||||
"group-data-[search-select-highlighted]:border-white "
|
||||
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
|
||||
"group-data-[search-select-highlighted]:hover:border-white"
|
||||
)
|
||||
_FILTER_MODIFIER_ROW_CLASS = (
|
||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
||||
|
||||
|
||||
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
|
||||
one node when cloning the shape from a ``<template>``, so labels are the only
|
||||
thing the JS sets — all classes and structure stay server-side."""
|
||||
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
|
||||
if extra_class:
|
||||
attributes.append(("class", extra_class))
|
||||
return Span(attributes=attributes, children=[text])
|
||||
|
||||
|
||||
# A placeholder option for rendering template prototypes (JS overwrites it).
|
||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[_label_slot(option["label"])],
|
||||
)
|
||||
|
||||
|
||||
def _combobox_shell(
|
||||
*,
|
||||
container_attributes: list[HTMLAttribute],
|
||||
pills: SafeText,
|
||||
search_attributes: list[HTMLAttribute],
|
||||
options_children: list[SafeText],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[SafeText] | None = None,
|
||||
) -> SafeText:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
Every combobox built on top of this shell has the same three regions in the
|
||||
same order: the ``pills`` region, the search box, and the options panel (which
|
||||
always carries a trailing no-results node). Callers supply the already-built
|
||||
``pills`` region, the ``search_attributes`` for the text box, the
|
||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||
rows or pills look.
|
||||
"""
|
||||
search = Input(attributes=search_attributes)
|
||||
|
||||
no_results = Div(
|
||||
attributes=[
|
||||
("data-search-select-no-results", ""),
|
||||
("class", _NO_RESULTS_CLASS),
|
||||
],
|
||||
children=["No results"],
|
||||
)
|
||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||
options_panel = Div(
|
||||
attributes=[
|
||||
("data-search-select-options", ""),
|
||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||
("class", options_class),
|
||||
],
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
*,
|
||||
name: str,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
always_visible: bool = False,
|
||||
items_visible: int = 5,
|
||||
items_scroll: int = 10,
|
||||
prefetch: int = 0,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
autofocus: bool = False,
|
||||
) -> SafeText:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(option) for option in (selected or [])]
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
|
||||
# ── Pills + their hidden inputs (the submitted channel) ──
|
||||
# Multi-select renders a removable Pill per value; single-select renders no
|
||||
# pill — the committed label shows inside the search box instead, with a
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[SafeText] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
label_slot=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
elif selected:
|
||||
option = selected[0]
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
search_value = option["label"]
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attrs: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
if autofocus:
|
||||
search_attrs.append(("autofocus", ""))
|
||||
if search_value:
|
||||
search_attrs.append(("value", search_value))
|
||||
|
||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||
option_rows = [_option_row(option) for option in options] if not search_url else []
|
||||
|
||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||
# multi-select adds chosen items. ──
|
||||
templates: list[SafeText] = []
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_option_row(_BLANK_OPTION)],
|
||||
)
|
||||
)
|
||||
if multi_select:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill")],
|
||||
children=[Pill("", value="", removable=True, label_slot=True)],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-name", name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true" if multi_select else "false"),
|
||||
("data-always-visible", "true" if always_visible else "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "true" if sync_url else "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attrs,
|
||||
options_children=option_rows,
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
def _filter_remove_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _FILTER_PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
|
||||
|
||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
||||
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
||||
symbol = "✓" if kind == "include" else "✗"
|
||||
css = (
|
||||
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
|
||||
)
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", css),
|
||||
("data-pill", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("data-search-select-type", kind),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
||||
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", _FILTER_MODIFIER_PILL_CLASS),
|
||||
("data-pill", ""),
|
||||
("data-search-select-modifier", modifier_value),
|
||||
],
|
||||
children=[_label_slot(label), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-search-select-action", action),
|
||||
("class", _FILTER_ACTION_BUTTON_CLASS),
|
||||
("title", title),
|
||||
],
|
||||
children=[symbol],
|
||||
)
|
||||
|
||||
|
||||
def _filter_option_row(value: str | int, label: str) -> SafeText:
|
||||
"""A value row with include (+) and exclude (−) buttons."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(value)),
|
||||
("data-label", label),
|
||||
("class", _FILTER_OPTION_ROW_CLASS),
|
||||
],
|
||||
children=[
|
||||
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
|
||||
Span(
|
||||
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
|
||||
children=[
|
||||
_filter_action_button("include", "+", "Include"),
|
||||
_filter_action_button("exclude", "−", "Exclude"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-modifier-option", modifier_value),
|
||||
("data-label", label),
|
||||
("class", _FILTER_MODIFIER_ROW_CLASS),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def FilterSelect(
|
||||
*,
|
||||
field_name: str,
|
||||
options: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
included: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
modifier: str = "",
|
||||
modifier_options: list[LabeledOption] | None = None,
|
||||
search_url: str = "",
|
||||
prefetch: int = 0,
|
||||
items_visible: int = 6,
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
) -> SafeText:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
||||
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
||||
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
|
||||
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
|
||||
INCLUDES_ONLY) coexist with value pills — they govern how the include set
|
||||
matches and are only surfaced for many-to-many fields. State is read from
|
||||
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
|
||||
is submitted by ``name``.
|
||||
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||
"""
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||
modifier_options = modifier_options or []
|
||||
|
||||
active_modifier_label = ""
|
||||
for modifier_value, label in modifier_options:
|
||||
if modifier_value == modifier:
|
||||
active_modifier_label = label
|
||||
break
|
||||
|
||||
# ── Pills: modifier pill (if active), then include/exclude value pills ──
|
||||
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
|
||||
# pills — but the stored state guarantees they never coexist, so we render
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[SafeText] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
for option in included:
|
||||
pills_children.append(_filter_value_pill(option, "include"))
|
||||
for option in excluded:
|
||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
|
||||
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
|
||||
# there is no search_url; otherwise the JS fetches them) ──
|
||||
modifier_rows = [
|
||||
_filter_modifier_row(value, label) for value, label in modifier_options
|
||||
]
|
||||
value_rows = (
|
||||
[_filter_option_row(option["value"], option["label"]) for option in options]
|
||||
if not search_url
|
||||
else []
|
||||
)
|
||||
|
||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||
templates: list[SafeText] = [
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-include")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||
),
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-exclude")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
|
||||
),
|
||||
]
|
||||
if modifier_options:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-modifier")],
|
||||
children=[_filter_modifier_pill("", "")],
|
||||
)
|
||||
)
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_filter_option_row("", "")],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-search-select-mode", "filter"),
|
||||
("data-name", field_name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true"),
|
||||
("data-always-visible", "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attributes,
|
||||
options_children=[*modifier_rows, *value_rows],
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
values: list,
|
||||
resolver: Callable[[list], Iterable[SearchSelectOption]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(option) for option in resolver(values)]
|
||||
@@ -1,512 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
INCLUDES_ONLY = "INCLUDES_ONLY"
|
||||
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.INCLUDES_ONLY,
|
||||
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 _SetCriterion(_Criterion):
|
||||
"""Shared base for set-membership criteria (``MultiCriterion`` /
|
||||
``ChoiceCriterion``).
|
||||
|
||||
Two orthogonal channels, mirroring Stash's modifier model:
|
||||
|
||||
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
|
||||
|
||||
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
|
||||
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
|
||||
many-to-many fields, e.g. a purchase's games).
|
||||
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
|
||||
alias.
|
||||
|
||||
- ``excludes`` is an *always-orthogonal* negative: it contributes
|
||||
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
|
||||
swapped into the include set. An exclude-only criterion therefore means
|
||||
"everything except ``excludes``".
|
||||
|
||||
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
|
||||
presence and ignore both lists.
|
||||
|
||||
The logic lives entirely here so the two subclasses (which differ only in
|
||||
their value type) cannot drift.
|
||||
"""
|
||||
|
||||
value: list = field(default_factory=list)
|
||||
excludes: list = field(default_factory=list)
|
||||
modifier: Modifier = Modifier.INCLUDES
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
modifier = self.modifier
|
||||
if modifier == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if modifier == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
# The modifier governs only the include set; ``excludes`` is an orthogonal
|
||||
# AND'd negative applied for every (non-presence) modifier.
|
||||
q = self._value_q(field_name)
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
|
||||
def _value_q(self, field_name: str) -> Q:
|
||||
"""Build the Q for the include (``value``) set, per the modifier."""
|
||||
modifier = self.modifier
|
||||
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
|
||||
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
|
||||
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
|
||||
# ("related to exactly these, nothing else") are only meaningful
|
||||
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
|
||||
# collapses to a single join requiring one through-row to equal
|
||||
# both values (impossible), so the generic criterion layer cannot
|
||||
# build a correct Q. M2M callers must supply their own Q builder
|
||||
# at the filter level — see PurchaseFilter._games_to_q for the
|
||||
# chained-subquery pattern.
|
||||
assert False, (
|
||||
f"{modifier} requires a filter-level Q builder for M2M fields. "
|
||||
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
||||
)
|
||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
result = super().from_json(data)
|
||||
if result is None:
|
||||
return None
|
||||
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
|
||||
# so the querying layer stays clean and typed.
|
||||
result.value = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.value
|
||||
]
|
||||
result.excludes = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.excludes
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiCriterion(_SetCriterion):
|
||||
"""Filter on a many-to-many or ForeignKey relationship by ID list.
|
||||
|
||||
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
|
||||
``_SetCriterion``; this subclass only refines the value type.
|
||||
"""
|
||||
|
||||
value: list[int] = field(default_factory=list)
|
||||
excludes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceCriterion(_SetCriterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
|
||||
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
|
||||
"""
|
||||
|
||||
value: list[str] = field(default_factory=list)
|
||||
excludes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── 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,25 +0,0 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_icons() -> dict[str, str]:
|
||||
"""Load all icon HTML files into a dict.
|
||||
|
||||
Cached so files are read once per process lifetime.
|
||||
Delegation (e.g. nintendo-3ds -> nintendo) is handled by
|
||||
both files containing identical SVG content.
|
||||
"""
|
||||
icons: dict[str, str] = {}
|
||||
for filepath in _ICON_DIR.glob("*.html"):
|
||||
name = filepath.stem
|
||||
icons[name] = filepath.read_text()
|
||||
return icons
|
||||
|
||||
|
||||
def get_icon(name: str) -> str:
|
||||
"""Return the HTML for an icon by name. Falls back to 'unspecified'."""
|
||||
icons = _load_icons()
|
||||
return icons.get(name, icons.get("unspecified", ""))
|
||||
@@ -20,8 +20,8 @@ def import_data(data: DataList):
|
||||
# try exact match first
|
||||
try:
|
||||
game_id = Game.objects.get(name__iexact=name)
|
||||
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||
game_id = None
|
||||
except:
|
||||
pass
|
||||
matching_names[name] = game_id
|
||||
print(f"Exact matched {len(matching_names)} games.")
|
||||
|
||||
|
||||
@@ -206,8 +206,8 @@ textarea:disabled {
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
input:not([type="checkbox"]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||
@@ -231,4 +231,3 @@ textarea:disabled {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -5,34 +5,11 @@ 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.
|
||||
@@ -176,9 +153,9 @@ def redirect_to(default_view: str, *default_args):
|
||||
|
||||
next_url = reverse(default_view, args=default_args)
|
||||
|
||||
# Execute the original view logic for its side effects, then
|
||||
# redirect to `next_url` instead of returning its response.
|
||||
view_func(request, *args, **kwargs)
|
||||
response = view_func(
|
||||
request, *args, **kwargs
|
||||
) # Execute the original view logic
|
||||
return redirect(next_url)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
# Form Overhaul Plan
|
||||
|
||||
> Last updated: 2026-05-12
|
||||
> Status: Decided — awaiting implementation
|
||||
>
|
||||
> **Decisions made:**
|
||||
> - All forms (simple and complex) get section headers for consistency
|
||||
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
|
||||
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
|
||||
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
|
||||
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Form Pages (add/edit)
|
||||
|
||||
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
|
||||
|
||||
| Page | Form | Fields | Complexity |
|
||||
|---|---|---|---|
|
||||
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
|
||||
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
|
||||
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
|
||||
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
|
||||
| Device | `DeviceForm` | 2 fields: name, type | Low |
|
||||
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
|
||||
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
|
||||
|
||||
### Other Form-Like Elements
|
||||
|
||||
| Element | Template | Notes |
|
||||
|---|---|---|
|
||||
| Login | `registration/login.html` | Flowbite card, already good |
|
||||
| Search | `cotton/search_field.html` | Reusable, already good |
|
||||
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
|
||||
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
|
||||
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
|
||||
---
|
||||
|
||||
## Issues to Fix
|
||||
|
||||
### P0: Broken/Inconsistent
|
||||
|
||||
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
|
||||
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
|
||||
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
|
||||
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
|
||||
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
|
||||
|
||||
### P1: Layout & UX
|
||||
|
||||
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
|
||||
7. **No field grouping** — related fields listed flat without visual hierarchy
|
||||
8. **Stats year `<select>`** has no Flowbite styling
|
||||
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
|
||||
|
||||
### P2: Styling Consistency
|
||||
|
||||
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
|
||||
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
|
||||
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Two-Column Layout for Complex Forms (Flexbox)
|
||||
|
||||
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
|
||||
|
||||
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
|
||||
|
||||
#### GameForm Layout
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Game Details │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Name │ Platform │ │
|
||||
│ │ Sort Name │ Year │ │
|
||||
│ │ Original Year │ Wikidata │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ Status │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Status │ Mastered │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ [Submit] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### PurchaseForm Layout (simplified)
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Purchase Details │
|
||||
│ ┌──────────────────────┬───────────────┐ │
|
||||
│ │ Games (multi-select) │ Platform │ │
|
||||
│ │ Type │ Ownership │ │
|
||||
│ │ Name │ Related Purch │ │
|
||||
│ └──────────────────────┴───────────────┘ │
|
||||
│ Dates │ Price │
|
||||
│ ┌───────────────┬──────┴───────────────┐ │
|
||||
│ │ Date Purch │ Price Curr │ │
|
||||
│ │ Date Refund │ Infinite [ ] │ │
|
||||
│ └───────────────┴──────────────────────┘ │
|
||||
│ [Submit] [Submit + Session] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
|
||||
|
||||
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
|
||||
|
||||
### 2. Field Grouping with Card Sections
|
||||
|
||||
**Decision**: ALL forms get section headers for consistency (not just complex forms).
|
||||
|
||||
Group related fields with section headings and subtle borders/backgrounds:
|
||||
|
||||
```html
|
||||
<c-section title="Game Details" columns="2">
|
||||
{{ form.name }}
|
||||
{{ form.platform }}
|
||||
{{ form.sort_name }}
|
||||
{{ form.year_released }}
|
||||
</c-section>
|
||||
```
|
||||
|
||||
Each section renders as:
|
||||
```html
|
||||
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Each section gets:
|
||||
- Subtle background (`bg-neutral-primary-soft/30`)
|
||||
- Top border with spacing (`border-t border-default-medium`)
|
||||
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
|
||||
- Flexbox gap for responsive field reflow
|
||||
|
||||
### 1b. `c-section` Component Specification
|
||||
|
||||
New cotton component for the `cotton/` directory:
|
||||
|
||||
```python
|
||||
# games/templates/cotton/section.py (or inline in components.py)
|
||||
from common.components import Div
|
||||
|
||||
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
|
||||
"""Renders a form field section with optional multi-column flexbox layout.
|
||||
|
||||
Args:
|
||||
title: Section heading (renders as uppercase label)
|
||||
columns: "1" (default), "2", or "3" — target column count on md+ screens
|
||||
children: Field markup (each field wrapped in <div> for flex wrapping)
|
||||
"""
|
||||
col_class = {
|
||||
"1": "flex flex-col",
|
||||
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
|
||||
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
|
||||
}.get(columns, "flex flex-col")
|
||||
|
||||
return Div(
|
||||
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
|
||||
children=f"""
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
|
||||
<div class="{col_class}">{children}</div>
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
**Template usage:**
|
||||
```django
|
||||
{# add_game.html #}
|
||||
<c-layouts.add title="New Game">
|
||||
<c-section title="Game Details" columns="2">
|
||||
<div>{{ form.name }}</div>
|
||||
<div>{{ form.platform }}</div>
|
||||
<div>{{ form.sort_name }}</div>
|
||||
<div>{{ form.year_released }}</div>
|
||||
<div>{{ form.original_year_released }}</div>
|
||||
<div>{{ form.wikidata }}</div>
|
||||
</c-section>
|
||||
<c-section title="Status" columns="2">
|
||||
<div>{{ form.status }}</div>
|
||||
<div>{{ form.mastered }}</div>
|
||||
</c-section>
|
||||
</c-layouts.add>
|
||||
```
|
||||
|
||||
**`cotton/layouts/add.html` changes:**
|
||||
- Remove hardcoded `{{ form.as_div }}` rendering
|
||||
- Accept optional `sections` variable (list of rendered `c-section` output)
|
||||
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
|
||||
- Keep `additional_row` slot for dual-submit buttons
|
||||
|
||||
### 3. CSS/Style Fixes
|
||||
|
||||
#### `input.css` changes:
|
||||
```css
|
||||
/* Update errorlist */
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
|
||||
}
|
||||
|
||||
/* Remove: #button-container, .basic-button — unused legacy */
|
||||
|
||||
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
|
||||
/* Remove: flowbite-input @apply block (line 229-234) */
|
||||
|
||||
/* Add Flowbite styling for select in stats */
|
||||
#yearSelect {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
|
||||
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
|
||||
- `select` — same base styling as inputs
|
||||
- `textarea` — same base styling with adjusted padding
|
||||
|
||||
**Files to clean up:**
|
||||
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
|
||||
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
|
||||
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
|
||||
|
||||
#### Rewrite `modal.html`:
|
||||
- Remove stray `</form>` tag and restructure as a proper cotton component
|
||||
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
|
||||
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
|
||||
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
|
||||
- Reusable `cotton/close_button.html` via `<c-close-button />`
|
||||
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
|
||||
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
|
||||
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
|
||||
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
|
||||
|
||||
### 4. Unify Delete Confirmations (All Modal)
|
||||
|
||||
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
|
||||
|
||||
**Target**: All confirmation modals use the same pattern:
|
||||
|
||||
```html
|
||||
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
|
||||
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
|
||||
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
|
||||
{% if details %}
|
||||
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
|
||||
<li>{{ detail }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<c-button color="red" class="w-full" type="submit">Delete</c-button>
|
||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
|
||||
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html` → `partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
|
||||
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
|
||||
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
|
||||
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
|
||||
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
|
||||
- Delete old `gamestatuschange_confirm_delete.html` after migration
|
||||
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
|
||||
|
||||
### 5. Search Form Enhancement
|
||||
|
||||
Wrap `search_field.html` in proper `<form method="get">`:
|
||||
|
||||
```html
|
||||
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
|
||||
<!-- input + button -->
|
||||
</form>
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Native form submission on Enter
|
||||
- Potential for "clear all" functionality
|
||||
- Proper browser form autofill behavior
|
||||
|
||||
### 6. Status/Device Selector Styling
|
||||
|
||||
Update Alpine.js dropdowns to use consistent button classes:
|
||||
- Replace `rounded-lg` with `rounded-base`
|
||||
- Replace `shadow-2xs` with `shadow-xs`
|
||||
- Standardize border colors with `border-default`
|
||||
- Use `text-heading` / `text-body` for dark mode compatibility
|
||||
|
||||
---
|
||||
|
||||
## Templates That Need Changes
|
||||
|
||||
| Template | Change | Effort |
|
||||
|---|---|---|
|
||||
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
|
||||
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
|
||||
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
|
||||
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
|
||||
| `add_playevent.html` | Flexbox layout, section headers | Low |
|
||||
| `add_platform.html` | Section headers (was flat single-column) | Low |
|
||||
| `add_device.html` | Section headers (was flat single-column) | Low |
|
||||
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
|
||||
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
|
||||
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
|
||||
| `stats.html` | Add Flowbite select styling | Low |
|
||||
| `partials/gamestatus_selector.html` | Update button classes | Low |
|
||||
| `partials/sessiondevice_selector.html` | Update button classes | Low |
|
||||
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
|
||||
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Quick Wins (low risk, no breaking changes)
|
||||
|
||||
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
|
||||
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
|
||||
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
|
||||
4. **Search field enhancement** — wrap in `<form method="get">`
|
||||
5. **Stats select styling** — add Flowbite select classes
|
||||
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
|
||||
|
||||
### Phase 2: `c-section` Component
|
||||
|
||||
7. **Create `c-section` component** — title, columns, fields slots
|
||||
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
|
||||
|
||||
### Phase 3: Form Layout Overhaul (largest change)
|
||||
|
||||
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
|
||||
10. **`PlayEventForm`** — section headers + 2-col flexbox
|
||||
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
|
||||
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
|
||||
13. **Simple forms** — `add_platform.html`, `add_device.html` get section headers (single column)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `make test` after Phase 1 changes to verify nothing broke
|
||||
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
|
||||
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] Simple forms section headers? → **All forms get section headers** for consistency
|
||||
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
|
||||
- [x] add.html layout variable? → **Option A** — `c-section` cotton component with `title` and `columns` slots
|
||||
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
|
||||
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
|
||||
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Section headers on simple forms | Yes, all forms get them |
|
||||
| Layout approach for multi-column | Flexbox with wrap |
|
||||
| Layout mechanism in add.html | Option A: `c-section` cotton component |
|
||||
| Purchase dual-submit | Simplify — single submit button, same as Game |
|
||||
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
|
||||
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
|
||||
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
|
||||
|
||||
## Build Step
|
||||
|
||||
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
|
||||
|
||||
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
|
||||
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
|
||||
|
||||
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
|
||||
Only use `make css` if you only want to rebuild CSS without starting the dev server.
|
||||
|
||||
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
|
||||
@@ -1 +0,0 @@
|
||||
# e2e tests package
|
||||
@@ -1,21 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# Playwright runs an async event loop in the background, which triggers
|
||||
# Django's async safety checks when running synchronous tests. This allows
|
||||
# synchronous operations inside the async context safely.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
|
||||
path = shutil.which(browser_name)
|
||||
if path:
|
||||
return {
|
||||
**browser_type_launch_args,
|
||||
"executable_path": path,
|
||||
}
|
||||
# Fallback to default Playwright behavior
|
||||
return browser_type_launch_args
|
||||
@@ -1,101 +0,0 @@
|
||||
import pytest
|
||||
from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SearchSelect E2E Test</title>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False
|
||||
)}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||
# Enable console log forwarding
|
||||
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
|
||||
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
# Inject our event logger
|
||||
page.evaluate("""() => {
|
||||
const s = document.querySelector('input[data-search-select-search]');
|
||||
const c = document.querySelector('[data-search-select]');
|
||||
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
}""")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
|
||||
# Focus the input
|
||||
print("\n--- FOCUSING INPUT ---")
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
|
||||
print("\n--- PRESSING BACKSPACE ---")
|
||||
page.keyboard.press("Backspace")
|
||||
|
||||
# Explicitly blur the input
|
||||
print("\n--- BLURRING INPUT ---")
|
||||
search_input.blur()
|
||||
|
||||
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
# After Backspace and blur, the input should remain empty (the selection is cleared)
|
||||
assert search_input.input_value() == ""
|
||||
assert hidden_input.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_typing_replaces_single_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
search_input.type("X")
|
||||
assert search_input.input_value() == "X"
|
||||
|
||||
search_input.blur()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
@@ -2,18 +2,15 @@ from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||
|
||||
from games.models import Device, Game, Platform, PlayEvent, Session
|
||||
from games.models import Game, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
device_router = Router()
|
||||
platform_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
@@ -53,27 +50,6 @@ class PlayEventOut(Schema):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GameOption(Schema): # mirrors SearchSelectOption
|
||||
value: int
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
@game_router.get("/search", response=list[GameOption])
|
||||
def search_games(request, q: str = "", limit: int = 10):
|
||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in qs[:limit]
|
||||
]
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
@@ -117,26 +93,8 @@ def delete_playevent(request, playevent_id: int):
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@device_router.get("/search", response=list[GameOption])
|
||||
def search_devices(request, q: str = "", limit: int = 10):
|
||||
qs = Device.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/search", response=list[GameOption])
|
||||
def search_platforms(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
api.add_router("/devices", device_router)
|
||||
api.add_router("/platforms", platform_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
@@ -146,9 +104,7 @@ class SessionDeviceUpdate(Schema):
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(
|
||||
request, session_id: int, payload: SessionDeviceUpdate
|
||||
):
|
||||
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
@@ -157,3 +113,4 @@ def partial_update_session_device(
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
"""
|
||||
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 django.db.models import Q
|
||||
|
||||
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:
|
||||
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:
|
||||
"""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
|
||||
|
||||
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:
|
||||
from datetime import timedelta
|
||||
|
||||
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:
|
||||
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(self.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
|
||||
|
||||
@staticmethod
|
||||
def _games_to_q(criterion: ChoiceCriterion) -> Q:
|
||||
"""Build the Q for the many-to-many ``games`` field.
|
||||
|
||||
``INCLUDES_ALL`` ("related to every selected game") and
|
||||
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
|
||||
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
|
||||
join and would require a single link row to be both games. Instead
|
||||
chain a filter per game so each gets its own join, then match by
|
||||
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
|
||||
any game outside the specified set.
|
||||
|
||||
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
|
||||
``games__in`` join because a single purchase linked to *n* of the
|
||||
given games would appear *n* times in the result set (M2M join
|
||||
duplicates).
|
||||
|
||||
The orthogonal ``excludes`` channel is applied as a negative,
|
||||
consistent with every other modifier. All other modifiers delegate
|
||||
to the criterion.
|
||||
"""
|
||||
# Empty value means no constraint; still apply excludes if any
|
||||
if not criterion.value:
|
||||
if criterion.excludes:
|
||||
return ~Q(games__in=criterion.excludes)
|
||||
return Q()
|
||||
|
||||
from games.models import Game, Purchase
|
||||
|
||||
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
subquery = Purchase.objects.all()
|
||||
for game_id in criterion.value:
|
||||
subquery = subquery.filter(games=game_id)
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||
extra_ids = Game.objects.exclude(
|
||||
id__in=criterion.value
|
||||
).values_list("id", flat=True)
|
||||
if extra_ids:
|
||||
subquery = subquery.exclude(games__in=extra_ids)
|
||||
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES:
|
||||
# Use subquery to avoid duplicate rows from M2M join
|
||||
subquery = Purchase.objects.filter(games__in=criterion.value)
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
return criterion.to_q("games")
|
||||
|
||||
|
||||
# ── 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,34 +2,27 @@
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
group: Nintendo
|
||||
@@ -1,13 +1,8 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.utils import safe_getattr
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
@@ -27,113 +22,18 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
|
||||
|
||||
def _game_options(values) -> list[SearchSelectOption]:
|
||||
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in Game.objects.filter(pk__in=values).select_related("platform")
|
||||
]
|
||||
|
||||
|
||||
def _device_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": d.id, "label": d.name, "data": {}}
|
||||
for d in Device.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
def _platform_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": p.id, "label": p.name, "data": {}}
|
||||
for p in Platform.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
class SearchSelectWidget(forms.Widget):
|
||||
"""Thin Django adapter that renders a `SearchSelect()` component.
|
||||
|
||||
The only place that knows about Django/forms — the component itself stays
|
||||
reusable outside forms.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_url,
|
||||
options_resolver,
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
):
|
||||
super().__init__(attrs)
|
||||
self.search_url = search_url
|
||||
self.options_resolver = options_resolver
|
||||
self.multi_select = multi_select
|
||||
self.items_visible = items_visible
|
||||
self.items_scroll = items_scroll
|
||||
self.prefetch = prefetch
|
||||
self.always_visible = always_visible
|
||||
self.placeholder = placeholder
|
||||
|
||||
@staticmethod
|
||||
def _values(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if v not in (None, "")]
|
||||
return [value] if value not in (None, "") else []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SearchSelectMultiple(SearchSelectWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
return data.get(name)
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
@@ -143,13 +43,7 @@ class SessionForm(forms.ModelForm):
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/devices/search", options_resolver=_device_options
|
||||
),
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
@@ -187,52 +81,37 @@ class SessionForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
def related_purchase_queryset():
|
||||
"""GAME purchases annotated with their first game's name.
|
||||
|
||||
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
|
||||
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
|
||||
query per option (700+ on a large library). Annotating the first game's
|
||||
name via a subquery lets the choice field build labels without those
|
||||
per-row queries.
|
||||
"""
|
||||
first_game_name = Subquery(
|
||||
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
|
||||
)
|
||||
return Purchase.objects.filter(type=Purchase.GAME).annotate(
|
||||
_first_game_name=first_game_name
|
||||
)
|
||||
|
||||
|
||||
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
# Mirrors Purchase.standardized_name but reads the annotated first-game
|
||||
# name instead of querying first_game per option.
|
||||
name = obj.name or getattr(obj, "_first_game_name", None)
|
||||
return name or obj.standardized_name
|
||||
class IncludePlatformSelect(forms.SelectMultiple):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
option["attrs"]["data-platform"] = platform_id
|
||||
return option
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected game.
|
||||
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
|
||||
self.fields["games"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_game_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectMultiple(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
multi_select=True,
|
||||
),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -307,11 +186,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -348,13 +223,9 @@ class DeviceForm(forms.ModelForm):
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
attrs={"autofocus": "autofocus"},
|
||||
),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
|
||||
@@ -34,11 +34,9 @@ class HTMXMessagesMiddleware:
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = (
|
||||
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
)
|
||||
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
|
||||
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
|
||||
@@ -6,265 +6,99 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
name='Device',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PC", "PC"),
|
||||
("Console", "Console"),
|
||||
("Handheld", "Handheld"),
|
||||
("Mobile", "Mobile"),
|
||||
("Single-board computer", "Single-board computer"),
|
||||
("Unknown", "Unknown"),
|
||||
],
|
||||
default="Unknown",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Platform",
|
||||
name='Platform',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"group",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
("icon", models.SlugField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('icon', models.SlugField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ExchangeRate",
|
||||
name='ExchangeRate',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("currency_from", models.CharField(max_length=255)),
|
||||
("currency_to", models.CharField(max_length=255)),
|
||||
("year", models.PositiveIntegerField()),
|
||||
("rate", models.FloatField()),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency_from', models.CharField(max_length=255)),
|
||||
('currency_to', models.CharField(max_length=255)),
|
||||
('year', models.PositiveIntegerField()),
|
||||
('rate', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("currency_from", "currency_to", "year")},
|
||||
'unique_together': {('currency_from', 'currency_to', 'year')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
name='Game',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"sort_name",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"year_released",
|
||||
models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
(
|
||||
"wikidata",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=50, null=True
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('year_released', models.IntegerField(blank=True, default=None, null=True)),
|
||||
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "platform", "year_released")},
|
||||
'unique_together': {('name', 'platform', 'year_released')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Purchase",
|
||||
name='Purchase',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
("date_finished", models.DateField(blank=True, null=True)),
|
||||
("date_dropped", models.DateField(blank=True, null=True)),
|
||||
("infinite", models.BooleanField(default=False)),
|
||||
("price", models.FloatField(default=0)),
|
||||
("price_currency", models.CharField(default="USD", max_length=3)),
|
||||
("converted_price", models.FloatField(null=True)),
|
||||
("converted_currency", models.CharField(max_length=3, null=True)),
|
||||
(
|
||||
"ownership_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("game", "Game"),
|
||||
("dlc", "DLC"),
|
||||
("season_pass", "Season Pass"),
|
||||
("battle_pass", "Battle Pass"),
|
||||
],
|
||||
default="game",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(blank=True, default="", max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"games",
|
||||
models.ManyToManyField(
|
||||
blank=True, related_name="purchases", to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
(
|
||||
"related_purchase",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_purchased', models.DateField()),
|
||||
('date_refunded', models.DateField(blank=True, null=True)),
|
||||
('date_finished', models.DateField(blank=True, null=True)),
|
||||
('date_dropped', models.DateField(blank=True, null=True)),
|
||||
('infinite', models.BooleanField(default=False)),
|
||||
('price', models.FloatField(default=0)),
|
||||
('price_currency', models.CharField(default='USD', max_length=3)),
|
||||
('converted_price', models.FloatField(null=True)),
|
||||
('converted_currency', models.CharField(max_length=3, null=True)),
|
||||
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
|
||||
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
|
||||
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
|
||||
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Session",
|
||||
name='Session',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"duration_manual",
|
||||
models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
("emulated", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("modified_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp_start', models.DateTimeField()),
|
||||
('timestamp_end', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
|
||||
('duration_calculated', models.DurationField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('emulated', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
|
||||
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "timestamp_start",
|
||||
'get_latest_by': 'timestamp_start',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
('games', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0002_purchase_price_per_game"),
|
||||
('games', '0002_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="updated_at",
|
||||
model_name='purchase',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,66 +5,55 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0005_game_mastered_game_status"),
|
||||
('games', '0005_game_mastered_game_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="sort_name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
model_name='game',
|
||||
name='sort_name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default="", max_length=50),
|
||||
model_name='game',
|
||||
name='wikidata',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
model_name='platform',
|
||||
name='group',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="converted_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
model_name='purchase',
|
||||
name='converted_currency',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="games",
|
||||
field=models.ManyToManyField(related_name="purchases", to="games.game"),
|
||||
model_name='purchase',
|
||||
name='games',
|
||||
field=models.ManyToManyField(related_name='purchases', to='games.game'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
model_name='purchase',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
model_name='purchase',
|
||||
name='related_purchase',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
model_name='session',
|
||||
name='game',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="note",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
model_name='session',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
|
||||
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="updated_at",
|
||||
model_name='game',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,17 +4,18 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_dropped",
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,13 +4,14 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0009_remove_purchase_date_dropped_and_more"),
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,24 +6,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_remove_purchase_price_per_game"),
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
django.db.models.functions.comparison.Coalesce(
|
||||
models.F("converted_price"), models.F("price"), 0
|
||||
),
|
||||
"/",
|
||||
models.F("num_purchases"),
|
||||
),
|
||||
output_field=models.FloatField(),
|
||||
),
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,20 +5,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0013_game_playtime"),
|
||||
('games', '0013_game_playtime'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_total",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
models.F("duration_calculated"), "+", models.F("duration_manual")
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
model_name='session',
|
||||
name='duration_total',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,39 +5,35 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0014_session_duration_total"),
|
||||
('games', '0014_session_duration_total'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_purchased",
|
||||
field=models.DateField(verbose_name="Purchased"),
|
||||
model_name='purchase',
|
||||
name='date_purchased',
|
||||
field=models.DateField(verbose_name='Purchased'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_refunded",
|
||||
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
|
||||
model_name='purchase',
|
||||
name='date_refunded',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True,
|
||||
default=datetime.timedelta(0),
|
||||
null=True,
|
||||
verbose_name="Manual duration",
|
||||
),
|
||||
model_name='session',
|
||||
name='duration_manual',
|
||||
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
|
||||
model_name='session',
|
||||
name='timestamp_end',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(verbose_name="Start"),
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,15 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0015_alter_purchase_date_purchased_and_more"),
|
||||
('games', '0015_alter_purchase_date_purchased_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="needs_price_update",
|
||||
model_name='purchase',
|
||||
name='needs_price_update',
|
||||
field=models.BooleanField(db_index=True, default=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0017_add_filter_preset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
@@ -65,15 +65,9 @@ class Game(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def search_label(self) -> str:
|
||||
return f"{self.sort_name} ({self.platform}, {self.year_released})"
|
||||
|
||||
def finished(self):
|
||||
return (
|
||||
self.status == self.Status.FINISHED
|
||||
or self.playevents.filter(ended__isnull=False).exists()
|
||||
)
|
||||
return (self.status == self.Status.FINISHED or
|
||||
self.playevents.filter(ended__isnull=False).exists())
|
||||
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
@@ -294,7 +288,7 @@ class Session(models.Model):
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||
duration_manual = models.DurationField(
|
||||
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||
@@ -333,6 +327,9 @@ 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
|
||||
@@ -484,33 +481,3 @@ 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()})"
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
|
||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||
// react to its custom "search-select:change" event instead of syncing a select.
|
||||
document.addEventListener("search-select:change", (event) => {
|
||||
if (event.detail.name !== "games") return;
|
||||
|
||||
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||
const last = event.detail.last;
|
||||
const platformId = last && last.data ? last.data.platform : "";
|
||||
if (platformId) {
|
||||
const platformEl = getEl("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||
const query = event.detail.values
|
||||
.map((value) => "games=" + encodeURIComponent(value))
|
||||
.join("&");
|
||||
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (response.status === 204) return null;
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
if (html === null) return;
|
||||
const target = getEl("#id_related_purchase");
|
||||
if (target) target.outerHTML = html;
|
||||
});
|
||||
});
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
@@ -42,4 +27,5 @@ document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* 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) {
|
||||
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" };
|
||||
}
|
||||
|
||||
// ── FilterSelect widgets (data-search-select-mode="filter") ──
|
||||
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
||||
readSearchSelect(form);
|
||||
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
|
||||
widgets.forEach(function (widget) {
|
||||
var field = widget.getAttribute("data-name");
|
||||
var included = parseJSONAttr(widget, "data-included");
|
||||
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||
// pinned (Any)/(None) pseudo-options clears the value set and has no
|
||||
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
|
||||
// how the include set matches. When neither is set the implicit default
|
||||
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
|
||||
var modifier = widget.getAttribute("data-modifier");
|
||||
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||
if (IS_PRESENCE) {
|
||||
filter[field] = { modifier: modifier };
|
||||
} else if (included.length > 0 || excluded.length > 0) {
|
||||
// All filter pills carry {id, label}; store them as-is so the filter
|
||||
// URL and saved presets are self-describing (Stash-style).
|
||||
filter[field] = {
|
||||
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||
modifier: modifier || "INCLUDES",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session-specific fields ──
|
||||
var pageIsSessions =
|
||||
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
|
||||
|
||||
// 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 !== "") {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Purchase-specific: num_purchases ──
|
||||
var numGamesMin = numberValue(form, "filter-num-purchases-min");
|
||||
var numGamesMax = numberValue(form, "filter-num-purchases-max");
|
||||
if (numGamesMin !== "" && numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
|
||||
} else if (numGamesMin !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
|
||||
} else if (numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMax, 10), 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* Range slider — custom draggable handles (no native <input type=range>).
|
||||
*
|
||||
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
||||
* range (default) — two handles, min ≤ max constraint
|
||||
* point — single handle, sets both number inputs to the same value
|
||||
*
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
(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 mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
|
||||
function getTargetValue(target) {
|
||||
return parseInt(target ? target.value : 0, 10) || dataMin;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minValue = getTargetValue(minTarget);
|
||||
var maxValue = getTargetValue(maxTarget);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minValue);
|
||||
var widthPct = valueToPercent(maxValue) - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles() {
|
||||
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
||||
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
|
||||
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 value = percentToValue(clamp(pct, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget))
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget), dataMax)
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
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 number inputs back to handles ──
|
||||
|
||||
function syncFromInputs() {
|
||||
if (mode === "point") {
|
||||
var value =
|
||||
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
if (minTarget)
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
if (maxTarget)
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, getTargetValue(maxTarget));
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
window.initRangeSliders = initAll;
|
||||
})();
|
||||
@@ -1,647 +0,0 @@
|
||||
/**
|
||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
||||
* Multi-select renders chosen items as removable pills (inline with the search
|
||||
* box), each backed by a hidden <input>. Single-select renders no pill: the
|
||||
* committed label lives inside the search box (which doubles as a combobox —
|
||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
||||
*
|
||||
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
|
||||
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
||||
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||
* element._searchSelectInit.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* the server renders with the same Python components (Pill / SearchSelect /
|
||||
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
|
||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
const initAll = () => {
|
||||
document.querySelectorAll("[data-search-select]").forEach(element => {
|
||||
if (element._searchSelectInit) return;
|
||||
element._searchSelectInit = true;
|
||||
initWidget(element);
|
||||
});
|
||||
};
|
||||
|
||||
const initWidget = (container) => {
|
||||
const search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
const name = container.getAttribute("data-name");
|
||||
const searchUrl = container.getAttribute("data-search-url");
|
||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
const multi = container.getAttribute("data-multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
const syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
const noResults = options.querySelector("[data-search-select-no-results]");
|
||||
let debounceTimer = null;
|
||||
let pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
let hasPrefetched = false;
|
||||
|
||||
const hasVisibleContent = () => {
|
||||
const optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||
for (let i = 0; i < optionRows.length; i++) {
|
||||
if (optionRows[i].style.display !== "none") return true;
|
||||
}
|
||||
if (noResults && !noResults.classList.contains("hidden")) return true;
|
||||
if (options.querySelector("[data-search-select-modifier-option]")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const showPanel = () => {
|
||||
if (alwaysVisible || hasVisibleContent()) {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
const hidePanel = () => {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
};
|
||||
|
||||
const setNoResults = (visible) => {
|
||||
if (!noResults) return;
|
||||
noResults.classList.toggle("hidden", !visible);
|
||||
if (visible) showPanel();
|
||||
};
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
let highlightedRow = null;
|
||||
|
||||
const highlightOption = (row) => {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.setAttribute("data-search-select-highlighted", "");
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const clearHighlight = () => {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||
highlightedRow = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibleOptions = () => {
|
||||
const all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.from(all).filter(row => row.style.display !== "none");
|
||||
};
|
||||
|
||||
const autoHighlight = (query) => {
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (let j = 0; j < visible.length; j++) {
|
||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.includes(lower)) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
};
|
||||
|
||||
// Get active values in both form and filter modes
|
||||
const getSelectedValues = () => {
|
||||
const vals = new Set();
|
||||
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
|
||||
vals.add(input.value);
|
||||
});
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const val = pill.getAttribute("data-value");
|
||||
if (val) vals.add(val);
|
||||
});
|
||||
return vals;
|
||||
};
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
const renderRows = (items) => {
|
||||
const selectedVals = getSelectedValues();
|
||||
const preservedOptions = [];
|
||||
|
||||
// Extract existing option data for currently selected values before removing
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => {
|
||||
const val = row.getAttribute("data-value");
|
||||
if (selectedVals.has(val)) {
|
||||
preservedOptions.push(optionFromRow(row));
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
|
||||
const renderedValues = new Set();
|
||||
|
||||
// Render preserved options first (to keep them at the top)
|
||||
preservedOptions.forEach(opt => {
|
||||
options.insertBefore(buildRow(opt), noResults || null);
|
||||
renderedValues.add(String(opt.value));
|
||||
});
|
||||
|
||||
// Render newly fetched items (excluding already rendered preserved ones)
|
||||
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
|
||||
items.forEach(item => {
|
||||
if (!renderedValues.has(String(item.value))) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
renderedValues.add(String(item.value));
|
||||
}
|
||||
});
|
||||
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||
const cloneTemplate = (name) => {
|
||||
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
|
||||
return template
|
||||
? template.content.firstElementChild.cloneNode(true)
|
||||
: null;
|
||||
};
|
||||
|
||||
const setLabel = (node, label) => {
|
||||
const slot = node.querySelector("[data-search-select-label]");
|
||||
if (slot) slot.textContent = label;
|
||||
};
|
||||
|
||||
const applyData = (node, data = {}) => {
|
||||
Object.keys(data).forEach(key => {
|
||||
node.setAttribute(`data-${key}`, data[key]);
|
||||
});
|
||||
};
|
||||
|
||||
// Build an option row by cloning the "row" template (the same prototype the
|
||||
// server renders, so fetched and pre-rendered rows are identical).
|
||||
const buildRow = (option) => {
|
||||
const row = cloneTemplate("row");
|
||||
if (!row) return document.createComment("ss-row");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
applyData(row, option.data);
|
||||
setLabel(row, option.label);
|
||||
row._searchSelectOption = option;
|
||||
return row;
|
||||
};
|
||||
|
||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||
// visible rows so the caller decides whether to show the no-results node. ──
|
||||
const filterRows = (query) => {
|
||||
const lower = query.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(item => {
|
||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
const match = label.includes(lower);
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
return visibleCount;
|
||||
};
|
||||
|
||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||
const fetchFromServer = (query) => {
|
||||
if (pendingRequest) pendingRequest.abort();
|
||||
pendingRequest = new AbortController();
|
||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||
.then(response => response.json())
|
||||
.then(items => {
|
||||
pendingRequest = null;
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.name === "AbortError") return; // superseded
|
||||
pendingRequest = null;
|
||||
setNoResults(true);
|
||||
});
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||
// so the client-side filter is authoritative.
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
autoHighlight(query);
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._searchSelectLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", () => {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
container._searchSelectDirty = false;
|
||||
}
|
||||
if (searchUrl) {
|
||||
if (prefetch && !hasPrefetched) {
|
||||
// Seed the window immediately on first open (not debounced).
|
||||
hasPrefetched = true;
|
||||
fetchFromServer("");
|
||||
} else {
|
||||
// Show whatever is already loaded; the server decides no-results.
|
||||
filterRows(search.value.trim());
|
||||
setNoResults(false);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
} else {
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
showPanel();
|
||||
});
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
clearHighlight();
|
||||
if (!multi) {
|
||||
if (!container._searchSelectDirty) {
|
||||
const label = container._searchSelectLabel || "";
|
||||
if (search.value.startsWith(label)) {
|
||||
search.value = search.value.slice(label.length);
|
||||
}
|
||||
container._searchSelectDirty = true;
|
||||
}
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
container._searchSelectLabel = "";
|
||||
emitChange(null);
|
||||
} else {
|
||||
// Focused-and-left, or typed a partial query without picking →
|
||||
// restore the committed label (no-op right after a selection).
|
||||
search.value = container._searchSelectLabel || "";
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (both form and filter modes) ──
|
||||
search.addEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
|
||||
event.preventDefault();
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(downIdx + 1) % visible.length]);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
const option = optionFromRow(highlightedRow);
|
||||
if (isFilter) {
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
} else {
|
||||
selectOption(option);
|
||||
}
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||
options.addEventListener("click", (event) => {
|
||||
if (isFilter) {
|
||||
handleFilterOptionClick(event);
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
const handleFilterOptionClick = (event) => {
|
||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
if (modifierRow) {
|
||||
setModifier(
|
||||
modifierRow.getAttribute("data-search-select-modifier-option"),
|
||||
modifierRow.getAttribute("data-label")
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
const button = event.target.closest("[data-search-select-action]");
|
||||
if (button) {
|
||||
const row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
const optionRow = event.target.closest("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
}
|
||||
};
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
const addFilterPill = (option, kind) => {
|
||||
const modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modPill) {
|
||||
const modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.includes(modVal)) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
const existing = pills.querySelector(
|
||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
const buildFilterValuePill = (option, kind) => {
|
||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
pill.setAttribute("data-label", option.label);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
const setModifier = (modifierValue, label) => {
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
const pill = cloneTemplate("pill-modifier");
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
const clearModifierPill = () => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
};
|
||||
|
||||
const clearModifier = () => {
|
||||
clearModifierPill();
|
||||
};
|
||||
|
||||
const optionFromRow = (row) => {
|
||||
if (row._searchSelectOption) return row._searchSelectOption;
|
||||
const data = {};
|
||||
Object.keys(row.dataset).forEach(key => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (multi) {
|
||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||
addPill(option);
|
||||
}
|
||||
search.value = "";
|
||||
} else {
|
||||
// Single-select: no pill — show the label in the search box and keep a
|
||||
// lone hidden input under [data-search-select-pills] for submission.
|
||||
pills.innerHTML = "";
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
search.value = option.label;
|
||||
container._searchSelectLabel = option.label;
|
||||
container._searchSelectDirty = false;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
};
|
||||
|
||||
const addPill = (option) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
};
|
||||
|
||||
const buildPill = (option) => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
const buildHidden = (value) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
};
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-pill-remove]");
|
||||
if (!removeButton) return;
|
||||
const pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input.
|
||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||
clearModifierPill();
|
||||
} else {
|
||||
pill.remove();
|
||||
}
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
const value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
const currentValues = () => {
|
||||
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
|
||||
};
|
||||
|
||||
const emitChange = (last) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const syncToUrl = (values) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(v => {
|
||||
params.append(name, v);
|
||||
});
|
||||
const qs = params.toString();
|
||||
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
|
||||
};
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(v => {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!container.contains(event.target)) hidePanel();
|
||||
});
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
window.readSearchSelect = (form) => {
|
||||
form.querySelectorAll("[data-search-select]").forEach(container => {
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (container.getAttribute("data-search-select-mode") === "filter") {
|
||||
const included = [];
|
||||
const excluded = [];
|
||||
let modifier = "";
|
||||
if (pills) {
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
const value = pill.getAttribute("data-value");
|
||||
const label = pill.getAttribute("data-label") || "";
|
||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||
excluded.push({ id: value, label });
|
||||
} else {
|
||||
included.push({ id: value, label });
|
||||
}
|
||||
});
|
||||
}
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
else container.removeAttribute("data-modifier");
|
||||
return;
|
||||
}
|
||||
const values = pills
|
||||
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
})();
|
||||
@@ -4,10 +4,10 @@ import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
# fixme: save preferred currency in user model
|
||||
currency_to = "CZK"
|
||||
currency_to = currency_to.upper()
|
||||
@@ -60,9 +60,7 @@ def _save_converted_price(purchase, converted_price, needs_update):
|
||||
purchase.converted_currency = currency_to
|
||||
if needs_update:
|
||||
purchase.needs_price_update = False
|
||||
purchase.save(
|
||||
update_fields=["converted_price", "converted_currency", "needs_price_update"]
|
||||
)
|
||||
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
|
||||
|
||||
|
||||
def convert_prices():
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<c-layouts.add>
|
||||
</c-layouts.add>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<c-vars color="blue" size="base" type="button" />
|
||||
<button
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if type %}type="{{ type }}"{% endif %}
|
||||
{% if title %}title="{{ title }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if data_target %}data-target="{{ data_target }}"{% endif %}
|
||||
{% if data_type %}data-type="{{ data_type }}"{% endif %}
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
||||
{{ slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="inline-flex rounded-md shadow-xs" role="group">
|
||||
{% if slot %}{{ slot }}{% endif %}
|
||||
{% for button in buttons %}
|
||||
{% if button.slot %}
|
||||
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<c-vars color="gray" />
|
||||
<a href="{{ href }}"
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if click %}@click="{{ click }}"{% endif %}
|
||||
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||
{% if color == "gray" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "red" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "green" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</a>
|
||||
@@ -0,0 +1,13 @@
|
||||
{% comment %}
|
||||
title
|
||||
text
|
||||
{% endcomment %}
|
||||
<a href="{{ link }}"
|
||||
title="{{ title }}"
|
||||
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
|
||||
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{% endcomment %}
|
||||
{{ text }}
|
||||
</a>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% comment %}
|
||||
title
|
||||
text
|
||||
{% endcomment %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
autofocus
|
||||
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{{ text }}
|
||||
</button>
|
||||
@@ -0,0 +1,10 @@
|
||||
<span class="truncate-container">
|
||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||
href="{% url 'games:view_game' game_id %}">
|
||||
{% if slot %}
|
||||
{{ slot }}
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</span>
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
|
||||
<span class="rounded-xl w-3 h-3
|
||||
{% if status == "u" %}
|
||||
bg-gray-500
|
||||
{% elif status == "p" %}
|
||||
bg-orange-400
|
||||
{% elif status == "f" %}
|
||||
bg-green-500
|
||||
{% elif status == "a" %}
|
||||
bg-red-500
|
||||
{% elif status == "r" %}
|
||||
bg-purple-500
|
||||
{% endif %}
|
||||
"> </span>
|
||||
{{ slot }}
|
||||
</span>
|
||||
@@ -0,0 +1,8 @@
|
||||
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
|
||||
{{ slot }}
|
||||
{% if badge %}
|
||||
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
|
||||
{{ badge }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Battle.net">
|
||||
<c-slot name="path">
|
||||
M 43.113281 22.152344 C 43.113281 22.152344 47.058594 22.351563 47.058594 20.03125 C 47.058594 16.996094 41.804688 14.261719 41.804688 14.261719 C 41.804688 14.261719 42.628906 12.515625 43.140625 11.539063 C 43.65625 10.5625 45.101563 6.753906 45.230469 5.886719 C 45.394531 4.792969 45.144531 4.449219 45.144531 4.449219 C 44.789063 6.792969 40.972656 13.539063 40.671875 13.769531 C 36.949219 12.023438 31.835938 11.539063 31.835938 11.539063 C 31.835938 11.539063 26.832031 1 22.125 1 C 17.457031 1 17.480469 10.023438 17.480469 10.023438 C 17.480469 10.023438 16.160156 7.464844 14.507813 7.464844 C 12.085938 7.464844 11.292969 11.128906 11.292969 15.097656 C 6.511719 15.097656 2.492188 16.164063 2.132813 16.265625 C 1.773438 16.371094 0.644531 17.191406 1.15625 17.089844 C 2.203125 16.753906 7.113281 15.992188 11.410156 16.367188 C 11.648438 20.140625 13.851563 25.054688 13.851563 25.054688 C 13.851563 25.054688 9.128906 31.894531 9.128906 36.78125 C 9.128906 38.066406 9.6875 40.417969 13.078125 40.417969 C 15.917969 40.417969 19.105469 38.710938 19.707031 38.363281 C 19.183594 39.113281 18.796875 40.535156 18.796875 41.191406 C 18.796875 41.726563 19.113281 43.246094 21.304688 43.246094 C 24.117188 43.246094 27.257813 41.089844 27.257813 41.089844 C 27.257813 41.089844 30.222656 46.019531 32.761719 48.28125 C 33.445313 48.890625 34.097656 49 34.097656 49 C 34.097656 49 31.578125 46.574219 28.257813 40.324219 C 31.34375 38.417969 34.554688 33.921875 34.554688 33.921875 C 34.554688 33.921875 34.933594 33.933594 37.863281 33.933594 C 42.453125 33.933594 48.972656 32.96875 48.972656 29.320313 C 48.972656 25.554688 43.113281 22.152344 43.113281 22.152344 Z M 43.625 19.886719 C 43.625 21.21875 42.359375 21.199219 42.359375 21.199219 L 41.394531 21.265625 C 41.394531 21.265625 39.566406 20.304688 38.460938 19.855469 C 38.460938 19.855469 40.175781 17.207031 40.578125 16.46875 C 40.882813 16.644531 43.625 18.363281 43.625 19.886719 Z M 24.421875 6.308594 C 26.578125 6.308594 29.65625 11.402344 29.65625 11.402344 C 29.65625 11.402344 24.851563 10.972656 20.898438 13.296875 C 21.003906 9.628906 22.238281 6.308594 24.421875 6.308594 Z M 15.871094 10.4375 C 16.558594 10.4375 17.230469 11.269531 17.507813 11.976563 C 17.507813 12.445313 17.75 15.171875 17.75 15.171875 L 13.789063 15.023438 C 13.789063 11.449219 15.1875 10.4375 15.871094 10.4375 Z M 15.464844 35.246094 C 13.300781 35.246094 12.851563 34.039063 12.851563 32.953125 C 12.851563 30.496094 14.8125 27.058594 14.8125 27.058594 C 14.8125 27.058594 17.011719 31.683594 20.851563 33.636719 C 18.945313 34.753906 17.375 35.246094 15.464844 35.246094 Z M 22.492188 40.089844 C 20.972656 40.089844 20.789063 39.105469 20.789063 38.878906 C 20.789063 38.171875 21.339844 37.335938 21.339844 37.335938 C 21.339844 37.335938 23.890625 35.613281 24.054688 35.429688 L 25.9375 38.945313 C 25.9375 38.945313 24.007813 40.089844 22.492188 40.089844 Z M 27.226563 38.171875 C 26.300781 36.554688 25.621094 34.867188 25.621094 34.867188 C 25.621094 34.867188 29.414063 35.113281 31.453125 33.007813 C 30.183594 33.578125 28.15625 34.300781 25.800781 34.082031 C 30.726563 29.742188 33.601563 26.597656 36.03125 23.34375 C 35.824219 23.09375 34.710938 22.316406 34.4375 22.1875 C 32.972656 23.953125 27.265625 30.054688 21.984375 33.074219 C 15.292969 29.425781 13.890625 18.691406 13.746094 16.460938 L 17.402344 16.8125 C 17.402344 16.8125 16.027344 19.246094 16.027344 21.039063 C 16.027344 22.828125 16.242188 22.925781 16.242188 22.925781 C 16.242188 22.925781 16.195313 19.800781 18.125 17.390625 C 19.59375 25.210938 21.125 29.21875 22.320313 31.605469 C 22.925781 31.355469 24.058594 30.851563 24.058594 30.851563 C 24.058594 30.851563 20.683594 21.121094 20.871094 14.535156 C 22.402344 13.71875 24.667969 12.875 27.226563 12.875 C 33.957031 12.875 39.367188 15.773438 39.367188 15.773438 L 37.25 18.730469 C 37.25 18.730469 35.363281 15.3125 32.699219 14.703125 C 34.105469 15.753906 35.679688 17.136719 36.496094 19.128906 C 30.917969 16.949219 24.1875 15.796875 22.027344 15.542969 C 21.839844 16.339844 21.863281 17.480469 21.863281 17.480469 C 21.863281 17.480469 30.890625 19.144531 37.460938 22.90625 C 37.414063 31.125 28.460938 37.4375 27.226563 38.171875 Z M 35.777344 32.027344 C 35.777344 32.027344 38.578125 28.347656 38.535156 23.476563 C 38.535156 23.476563 43.0625 26.28125 43.0625 29.015625 C 43.0625 32.074219 35.777344 32.027344 35.777344 32.027344 Z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg viewBox="0 0 20 20">
|
||||
<c-slot name="path">
|
||||
M2.069,11 L5,11 L5,9 L2.069,9 C2.252,7.542 2.828,6.208 3.688,5.102 L5.757,7.172 L7.171,5.757 L5.102,3.688 C6.208,2.828 8,2.252 9,2.069 L9,5 L11,5 L11,2.069 C12,2.252 13.791,2.828 14.897,3.688 L12.828,5.757 L14.242,7.172 L16.311,5.102 C17.171,6.208 17.747,7.542 17.93,9 L15,9 L15,11 L17.93,11 C17.747,12.458 17.171,13.792 16.311,14.898 L14.242,12.828 L12.828,14.243 L14.897,16.312 C13.791,17.172 12,17.748 11,17.931 L11,15 L9,15 L9,17.931 C8,17.748 6.208,17.172 5.102,16.312 L7.171,14.243 L5.757,12.828 L3.688,14.898 C2.828,13.792 2.252,12.458 2.069,11 M10,0 C4.477,0 0,4.477 0,10 C0,15.523 4.477,20 10,20 C15.522,20 20,15.523 20,10 C20,4.477 15.522,0 10,0
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
|
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 |
@@ -0,0 +1,9 @@
|
||||
<c-svg viewbox="0 0 50 50">
|
||||
<g transform="scale(0.09765625)">
|
||||
<title>EA/Origin</title>
|
||||
<g>
|
||||
<path fill="currentColor" d="M299.125,126.274H126.628L97.876,183.93h172.499L299.125,126.274z" />
|
||||
<path fill="currentColor" d="M342.248,126.274L224.462,328.066h-105.8l32.862-57.653h61.347l28.758-57.658H69.125l-28.746,57.658H85.31L26.001,385.727h232.784l83.463-153.654l18.169,38.342h-18.169l-28.75,57.654h75.67l28.75,57.658h68.081L342.248,126.274z" />
|
||||
</g>
|
||||
</g>
|
||||
</c-svg>
|
||||
|
Before Width: | Height: | Size: 798 B After Width: | Height: | Size: 798 B |
@@ -0,0 +1,6 @@
|
||||
<c-vars title="Epic Games Store" />
|
||||
<c-svg :title=title viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 10 3 C 6.69 3 4 5.69 4 9 L 4 41.240234 L 25 47.539062 L 46 41.240234 L 46 9 C 46 5.69 43.31 3 40 3 L 10 3 z M 11 8 L 15 8 L 15 11 L 11 11 L 11 18 L 14 18 L 14 21 L 11 21 L 11 28 L 15 28 L 15 31 L 11 31 C 9.34 31 8 29.66 8 28 L 8 11 C 8 9.34 9.34 8 11 8 z M 17 8 L 23 8 C 24.66 8 26 9.34 26 11 L 26 18 C 26 19.66 24.66 21 23 21 L 20 21 L 20 31 L 17 31 L 17 8 z M 28 8 L 31 8 L 31 31 L 28 31 L 28 8 z M 36 8 L 39 8 C 40.66 8 42 9.34 42 11 L 42 15 L 39 15 L 39 11 L 36 11 L 36 28 L 39 28 L 39 24 L 42 24 L 42 28 C 42 29.66 40.66 31 39 31 L 36 31 C 34.34 31 33 29.66 33 28 L 33 11 C 33 9.34 34.34 8 36 8 z M 20 11 L 20 18 L 23 18 L 23 11 L 20 11 z M 9 34 L 13 34 C 13.55 34 14 34.45 14 35 L 14 36 L 13 36 L 13 35.25 C 13 35.11 12.89 35 12.75 35 L 9.25 35 C 9.11 35 9 35.11 9 35.25 L 9 38.75 C 9 38.89 9.11 39 9.25 39 L 12.75 39 C 12.89 39 13 38.89 13 38.75 L 13 38 L 12 38 L 12 37 L 14 37 L 14 39 C 14 39.55 13.55 40 13 40 L 9 40 C 8.45 40 8 39.55 8 39 L 8 35 C 8 34.45 8.45 34 9 34 z M 18 34 L 19 34 L 22 40 L 21 40 L 20.5 39 L 16.5 39 L 16 40 L 15 40 L 18 34 z M 23 34 L 24 34 L 26 38 L 28 34 L 29 34 L 29 40 L 28 40 L 28 36 L 26.5 39 L 25.5 39 L 24 36 L 24 40 L 23 40 L 23 34 z M 30 34 L 35 34 L 35 35 L 31 35 L 31 36.5 L 33 36.5 L 33 37.5 L 31 37.5 L 31 39 L 35 39 L 35 40 L 30 40 L 30 34 z M 37 34 L 41 34 C 41.55 34 42 34.45 42 35 L 42 35.5 L 41 35.5 L 41 35.25 C 41 35.11 40.89 35 40.75 35 L 37.25 35 C 37.11 35 37 35.11 37 35.25 L 37 36.25 C 37 36.39 37.11 36.5 37.25 36.5 L 41 36.5 C 41.55 36.5 42 36.95 42 37.5 L 42 39 C 42 39.55 41.55 40 41 40 L 37 40 C 36.45 40 36 39.55 36 39 L 36 38.5 L 37 38.5 L 37 38.75 C 37 38.89 37.11 39 37.25 39 L 40.75 39 C 40.89 39 41 38.89 41 38.75 L 41 37.75 C 41 37.61 40.89 37.5 40.75 37.5 L 37 37.5 C 36.45 37.5 36 37.05 36 36.5 L 36 35 C 36 34.45 36.45 34 37 34 z M 18.5 35 L 17 38 L 20 38 L 18.5 35 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<c-vars title="Emulated" />
|
||||
<c-svg :title=title viewbox="0 0 48 48">
|
||||
<c-slot name="path">
|
||||
M 8.5 5 C 6.0324991 5 4 7.0324991 4 9.5 L 4 30.5 C 4 32.967501 6.0324991 35 8.5 35 L 17 35 L 17 40 L 13.5 40 A 1.50015 1.50015 0 1 0 13.5 43 L 18.253906 43 A 1.50015 1.50015 0 0 0 18.740234 43 L 29.253906 43 A 1.50015 1.50015 0 0 0 29.740234 43 L 34.5 43 A 1.50015 1.50015 0 1 0 34.5 40 L 31 40 L 31 35 L 39.5 35 C 41.967501 35 44 32.967501 44 30.5 L 44 9.5 C 44 7.0324991 41.967501 5 39.5 5 L 8.5 5 z M 8.5 8 L 39.5 8 C 40.346499 8 41 8.6535009 41 9.5 L 41 30.5 C 41 31.346499 40.346499 32 39.5 32 L 29.746094 32 A 1.50015 1.50015 0 0 0 29.259766 32 L 18.746094 32 A 1.50015 1.50015 0 0 0 18.259766 32 L 8.5 32 C 7.6535009 32 7 31.346499 7 30.5 L 7 9.5 C 7 8.6535009 7.6535009 8 8.5 8 z M 17.5 12 C 16.136406 12 15 13.136406 15 14.5 L 15 25.5 C 15 26.863594 16.136406 28 17.5 28 L 30.5 28 C 31.863594 28 33 26.863594 33 25.5 L 33 14.5 C 33 13.136406 31.863594 12 30.5 12 L 17.5 12 z M 18 18 L 30 18 L 30 25 L 18 25 L 18 18 z M 20 35 L 28 35 L 28 40 L 20 40 L 20 35 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
|
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 |
@@ -0,0 +1,5 @@
|
||||
<c-svg title="GOG.com" viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 5.75 6 C 3.703125 6 2 7.703125 2 9.75 L 2 16.25 C 2 18.296875 3.703125 20 5.75 20 L 12 20 L 12 22 L 4 22 C 3.277344 21.988281 2.609375 22.367188 2.246094 22.992188 C 1.878906 23.613281 1.878906 24.386719 2.246094 25.007813 C 2.609375 25.632813 3.277344 26.011719 4 26 L 12.25 26 C 14.296875 26 16 24.296875 16 22.25 L 16 15.75 C 16.003906 15.6875 16.003906 15.625 16 15.5625 L 16 9.75 C 16 7.703125 14.296875 6 12.25 6 Z M 21.75 6 C 19.703125 6 18 7.703125 18 9.75 L 18 16.25 C 18 18.296875 19.703125 20 21.75 20 L 28.25 20 C 30.296875 20 32 18.296875 32 16.25 L 32 9.75 C 32 7.703125 30.296875 6 28.25 6 Z M 37.75 6 C 35.703125 6 34 7.703125 34 9.75 L 34 16.25 C 34 18.296875 35.703125 20 37.75 20 L 44 20 L 44 22 L 36 22 C 35.277344 21.988281 34.609375 22.367188 34.246094 22.992188 C 33.878906 23.613281 33.878906 24.386719 34.246094 25.007813 C 34.609375 25.632813 35.277344 26.011719 36 26 L 44.25 26 C 46.296875 26 48 24.296875 48 22.25 L 48 15.75 C 48.003906 15.6875 48.003906 15.625 48 15.5625 L 48 9.75 C 48 7.703125 46.296875 6 44.25 6 Z M 6 10 L 12 10 L 12 15.59375 C 11.996094 15.644531 11.996094 15.699219 12 15.75 L 12 16 L 6 16 Z M 22 10 L 28 10 L 28 16 L 22 16 Z M 38 10 L 44 10 L 44 15.59375 C 43.996094 15.644531 43.996094 15.699219 44 15.75 L 44 16 L 38 16 Z M 5.75 30 C 3.703125 30 2 31.703125 2 33.75 L 2 40.25 C 2 42.296875 3.703125 44 5.75 44 L 12 44 C 12.722656 44.011719 13.390625 43.632813 13.753906 43.007813 C 14.121094 42.386719 14.121094 41.613281 13.753906 40.992188 C 13.390625 40.367188 12.722656 39.988281 12 40 L 6 40 L 6 34 L 12 34 C 12.722656 34.011719 13.390625 33.632813 13.753906 33.007813 C 14.121094 32.386719 14.121094 31.613281 13.753906 30.992188 C 13.390625 30.367188 12.722656 29.988281 12 30 Z M 19.75 30 C 17.703125 30 16 31.703125 16 33.75 L 16 40.25 C 16 42.296875 17.703125 44 19.75 44 L 26.25 44 C 28.296875 44 29.996094 42.296875 30 40.25 L 30 33.75 C 30 31.703125 28.296875 30 26.25 30 Z M 38.65625 30 C 38.65625 30 37.933594 30 37.15625 30.03125 C 36.769531 30.046875 36.355469 30.066406 36 30.09375 C 35.824219 30.105469 35.667969 30.136719 35.5 30.15625 C 35.332031 30.175781 35.242188 30.152344 34.8125 30.3125 C 33.738281 30.714844 32.972656 31.429688 32.4375 32.4375 C 32.5 32.320313 32.355469 32.496094 32.21875 32.90625 C 32.082031 33.316406 32.078125 33.566406 32.0625 33.875 C 32.03125 34.496094 32.011719 35.507813 32 37.8125 C 31.992188 39.167969 32 40.203125 32 40.90625 C 32 41.257813 31.996094 41.515625 32 41.71875 C 32 41.820313 31.996094 41.914063 32 42 C 32 42.042969 31.992188 42.085938 32 42.15625 C 32.007813 42.226563 31.914063 42.171875 32.125 42.71875 C 32.453125 43.707031 33.484375 44.277344 34.492188 44.035156 C 35.503906 43.789063 36.160156 42.808594 36 41.78125 C 36 41.777344 36 41.722656 36 41.71875 C 36 41.703125 36 41.707031 36 41.6875 C 35.996094 41.527344 36 41.25 36 40.90625 C 36 40.21875 35.992188 39.199219 36 37.84375 C 36.011719 35.640625 36.011719 34.671875 36.03125 34.25 C 36.101563 34.183594 36.167969 34.117188 36.1875 34.09375 C 36.230469 34.089844 36.257813 34.097656 36.3125 34.09375 C 36.585938 34.074219 36.949219 34.046875 37.3125 34.03125 C 37.667969 34.015625 37.75 34.003906 38 34 L 38 42 C 37.988281 42.722656 38.367188 43.390625 38.992188 43.753906 C 39.613281 44.121094 40.386719 44.121094 41.007813 43.753906 C 41.632813 43.390625 42.011719 42.722656 42 42 L 42 34 C 42.800781 34 43.28125 34 44 34 L 44 42 C 43.988281 42.722656 44.367188 43.390625 44.992188 43.753906 C 45.613281 44.121094 46.386719 44.121094 47.007813 43.753906 C 47.632813 43.390625 48.011719 42.722656 48 42 L 48 30 L 46 30 C 46 30 39.59375 29.996094 38.6875 30 Z M 20 34 L 26 34 L 26 40 L 20 40 Z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,6 @@
|
||||
<c-svg title="Itch.io" viewBox="0 0 245.371 220.736" preserveAspectRatio="xMidYMid meet">
|
||||
<c-slot name="path">
|
||||
M31.99 1.365C21.287 7.72.2 31.945 0 38.298v10.516C0 62.144 12.46 73.86 23.773 73.86c13.584 0 24.902-11.258 24.903-24.62 0 13.362 10.93 24.62 24.515 24.62 13.586 0 24.165-11.258 24.165-24.62 0 13.362 11.622 24.62 25.207 24.62h.246c13.586 0 25.208-11.258 25.208-24.62 0 13.362 10.58 24.62 24.164 24.62 13.585 0 24.515-11.258 24.515-24.62 0 13.362 11.32 24.62 24.903 24.62 11.313 0 23.773-11.714 23.773-25.046V38.298c-.2-6.354-21.287-30.58-31.988-36.933C180.118.197 157.056-.005 122.685 0c-34.37.003-81.228.54-90.697 1.365zm65.194 66.217a28.025 28.025 0 0 1-4.78 6.155c-5.128 5.014-12.157 8.122-19.906 8.122a28.482 28.482 0 0 1-19.948-8.126c-1.858-1.82-3.27-3.766-4.563-6.032l-.006.004c-1.292 2.27-3.092 4.215-4.954 6.037a28.5 28.5 0 0 1-19.948 8.12c-.934 0-1.906-.258-2.692-.528-1.092 11.372-1.553 22.24-1.716 30.164l-.002.045c-.02 4.024-.04 7.333-.06 11.93.21 23.86-2.363 77.334 10.52 90.473 19.964 4.655 56.7 6.775 93.555 6.788h.006c36.854-.013 73.59-2.133 93.554-6.788 12.883-13.14 10.31-66.614 10.52-90.474-.022-4.596-.04-7.905-.06-11.93l-.003-.045c-.162-7.926-.623-18.793-1.715-30.165-.786.27-1.757.528-2.692.528a28.5 28.5 0 0 1-19.948-8.12c-1.862-1.822-3.662-3.766-4.955-6.037l-.006-.004c-1.294 2.266-2.705 4.213-4.563 6.032a28.48 28.48 0 0 1-19.947 8.125c-7.748 0-14.778-3.11-19.906-8.123a28.025 28.025 0 0 1-4.78-6.155 27.99 27.99 0 0 1-4.736 6.155 28.49 28.49 0 0 1-19.95 8.124c-.27 0-.54-.012-.81-.02h-.007c-.27.008-.54.02-.813.02a28.49 28.49 0 0 1-19.95-8.123 27.992 27.992 0 0 1-4.736-6.155zm-20.486 26.49l-.002.01h.015c8.113.017 15.32 0 24.25 9.746 7.028-.737 14.372-1.105 21.722-1.094h.006c7.35-.01 14.694.357 21.723 1.094 8.93-9.747 16.137-9.73 24.25-9.746h.014l-.002-.01c3.833 0 19.166 0 29.85 30.007L210 165.244c8.504 30.624-2.723 31.373-16.727 31.4-20.768-.773-32.267-15.855-32.267-30.935-11.496 1.884-24.907 2.826-38.318 2.827h-.006c-13.412 0-26.823-.943-38.318-2.827 0 15.08-11.5 30.162-32.267 30.935-14.004-.027-25.23-.775-16.726-31.4L46.85 124.08C57.534 94.073 72.867 94.073 76.7 94.073zm45.985 23.582v.006c-.02.02-21.863 20.08-25.79 27.215l14.304-.573v12.474c0 .584 5.74.346 11.486.08h.006c5.744.266 11.485.504 11.485-.08v-12.474l14.304.573c-3.928-7.135-25.79-27.215-25.79-27.215v-.006l-.003.002z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" height="235.452" width="261.728" viewBox="0 0 245.371 220.736"><path d="" color="#000" /></svg> {% endcomment %}
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Microsoft Store" viewbox="0 0 30 30">
|
||||
<c-slot name="path">
|
||||
M 6 4 C 4.895 4 4 4.895 4 6 L 4 12 C 4 13.105 4.895 14 6 14 L 12 14 C 13.105 14 14 13.105 14 12 L 14 6 C 14 4.895 13.105 4 12 4 L 6 4 z M 18 4 C 16.895 4 16 4.895 16 6 L 16 12 C 16 13.105 16.895 14 18 14 L 24 14 C 25.105 14 26 13.105 26 12 L 26 6 C 26 4.895 25.105 4 24 4 L 18 4 z M 6 16 C 4.895 16 4 16.895 4 18 L 4 24 C 4 25.105 4.895 26 6 26 L 12 26 C 13.105 26 14 25.105 14 24 L 14 18 C 14 16.895 13.105 16 12 16 L 6 16 z M 18 16 C 16.895 16 16 16.895 16 18 L 16 24 C 16 25.105 16.895 26 18 26 L 24 26 C 25.105 26 26 25.105 26 24 L 26 18 C 26 16.895 25.105 16 24 16 L 18 16 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1 @@
|
||||
<c-icon.nintendo />
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Nintendo Switch" viewbox="0 0 32 32">
|
||||
<c-slot name="path">
|
||||
M18.901 32h4.901c4.5 0 8.198-3.698 8.198-8.198v-15.604c0-4.5-3.698-8.198-8.198-8.198h-5c-0.099 0-0.203 0.099-0.203 0.198v31.604c0 0.099 0.099 0.198 0.302 0.198zM25 14.401c1.802 0 3.198 1.5 3.198 3.198 0 1.802-1.5 3.198-3.198 3.198-1.802 0-3.198-1.396-3.198-3.198-0.104-1.797 1.396-3.198 3.198-3.198zM15.198 0h-7c-4.5 0-8.198 3.698-8.198 8.198v15.604c0 4.5 3.698 8.198 8.198 8.198h7c0.099 0 0.203-0.099 0.203-0.198v-31.604c0-0.099-0.099-0.198-0.203-0.198zM12.901 29.401h-4.703c-3.099 0-5.599-2.5-5.599-5.599v-15.604c0-3.099 2.5-5.599 5.599-5.599h4.604zM5 9.599c0 1.698 1.302 3 3 3s3-1.302 3-3c0-1.698-1.302-3-3-3s-3 1.302-3 3z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,6 @@
|
||||
<c-vars title="Nintendo" />
|
||||
<c-svg viewBox="0 0 24 24">
|
||||
<c-slot name="path">
|
||||
M0 .6h7.1l9.85 15.9V.6H24v22.8h-7.04L7.06 7.5v15.9H0V.6
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg viewbox="0 0 512 512">
|
||||
<title>Physical Media</title>
|
||||
<path fill="currentColor" d="M277.333,256c0-11.755-9.557-21.333-21.333-21.333s-21.333,9.579-21.333,21.333c0,11.755,9.557,21.333,21.333,21.333 S277.333,267.755,277.333,256z" />
|
||||
<path fill="currentColor" d="M256,0C114.837,0,0,114.837,0,256s114.837,256,256,256s256-114.837,256-256S397.163,0,256,0z M128,256 c0,11.776-9.536,21.333-21.333,21.333c-11.797,0-21.333-9.557-21.333-21.333c0-94.101,76.565-170.667,170.667-170.667 c11.797,0,21.333,9.557,21.333,21.333S267.797,128,256,128C185.408,128,128,185.408,128,256z M192,256c0-35.285,28.715-64,64-64 s64,28.715,64,64s-28.715,64-64,64S192,291.285,192,256z M256,426.667c-11.797,0-21.333-9.557-21.333-21.333S244.203,384,256,384 c70.592,0,128-57.408,128-128c0-11.776,9.536-21.333,21.333-21.333s21.333,9.557,21.333,21.333 C426.667,350.101,350.101,426.667,256,426.667z" />
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg viewbox="0 0 512 512">
|
||||
<title>Physical Media</title>
|
||||
<path fill="currentColor" d="M277.333,256c0-11.755-9.557-21.333-21.333-21.333s-21.333,9.579-21.333,21.333c0,11.755,9.557,21.333,21.333,21.333 S277.333,267.755,277.333,256z" />
|
||||
<path fill="currentColor" d="M256,0C114.837,0,0,114.837,0,256s114.837,256,256,256s256-114.837,256-256S397.163,0,256,0z M128,256 c0,11.776-9.536,21.333-21.333,21.333c-11.797,0-21.333-9.557-21.333-21.333c0-94.101,76.565-170.667,170.667-170.667 c11.797,0,21.333,9.557,21.333,21.333S267.797,128,256,128C185.408,128,128,185.408,128,256z M192,256c0-35.285,28.715-64,64-64 s64,28.715,64,64s-28.715,64-64,64S192,291.285,192,256z M256,426.667c-11.797,0-21.333-9.557-21.333-21.333S244.203,384,256,384 c70.592,0,128-57.408,128-128c0-11.776,9.536-21.333,21.333-21.333s21.333,9.557,21.333,21.333 C426.667,350.101,350.101,426.667,256,426.667z" />
|
||||
</c-svg>
|
||||
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 834 B |
@@ -0,0 +1,6 @@
|
||||
<c-vars title="Playstation 1" />
|
||||
<c-svg viewBox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 19.3125 4 C 19.011719 4 18.707031 3.988281 18.40625 4.1875 C 18.105469 4.386719 18 4.699219 18 5 L 18 41.59375 C 18 41.992188 18.289063 42.394531 18.6875 42.59375 L 26.6875 45 L 27 45 C 27.199219 45 27.394531 44.914063 27.59375 44.8125 C 27.894531 44.613281 28 44.300781 28 44 L 28 13.40625 C 28.601563 13.707031 29 14.300781 29 15 L 29 26.09375 C 29 26.394531 29.199219 26.804688 29.5 26.90625 C 29.699219 27.007813 31.199219 27.90625 34 27.90625 C 36.699219 27.90625 40 26.414063 40 19.3125 C 40 13.613281 36.8125 9.292969 31.3125 7.59375 Z M 17 26.40625 L 5.90625 30.40625 L 4.3125 31 C 1.613281 32.101563 0 33.886719 0 35.6875 C 0 39.488281 2.699219 41.6875 7.5 41.6875 C 10.101563 41.6875 13.300781 41.113281 17 39.8125 L 17 36 C 16.101563 36.300781 15.113281 36.699219 14.3125 37 C 12.710938 37.601563 11.5 37.8125 10.5 37.8125 C 9 37.8125 8.300781 37.300781 8 37 C 7.601563 36.699219 7.398438 36.3125 7.5 35.8125 C 7.601563 34.8125 8.800781 33.894531 11 33.09375 C 11.5 32.894531 14.898438 31.699219 17 31 Z M 36.5 28.90625 C 34.101563 29.007813 31.601563 29.394531 29 30.09375 L 29 34.6875 C 30.101563 34.289063 31.585938 33.800781 33.6875 33 C 38.488281 31.300781 40.492188 31.488281 41.09375 31.6875 C 42.292969 31.789063 42.800781 32.5 43 33 C 43.5 34.5 41.613281 35.1875 38.8125 36.1875 C 37.511719 36.6875 31.898438 38.6875 29 39.6875 L 29 44.3125 L 44.5 38.8125 L 45.6875 38.3125 C 47.6875 37.613281 50.199219 36.300781 50 34 C 49.898438 31.800781 47.210938 30.695313 45.3125 30.09375 C 42.511719 29.195313 39.5 28.804688 36.5 28.90625 Z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
@@ -0,0 +1 @@
|
||||
<c-icon.playstation />
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Playstation 3" viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 1 19 A 1.0001 1.0001 0 1 0 1 21 L 12.5 21 C 13.340812 21 14 21.659188 14 22.5 C 14 23.340812 13.340812 24 12.5 24 L 3 24 C 1.3550302 24 0 25.35503 0 27 L 0 30 A 1.0001 1.0001 0 1 0 2 30 L 2 27 C 2 26.43497 2.4349698 26 3 26 L 12.5 26 C 14.28508 26 15.719786 24.619005 15.921875 22.884766 A 1.0001 1.0001 0 0 0 16 22.5 C 16 20.578812 14.421188 19 12.5 19 L 1 19 z M 26 19 C 24.35503 19 23 20.35503 23 22 L 23 28 C 23 28.56503 22.56503 29 22 29 L 16 29 A 1.0001 1.0001 0 1 0 16 31 L 22 31 C 23.64497 31 25 29.64497 25 28 L 25 22 C 25 21.43497 25.43497 21 26 21 L 32 21 A 1.0001 1.0001 0 1 0 32 19 L 26 19 z M 36 19 A 1.0001 1.0001 0 1 0 36 21 L 46.5 21 C 47.340812 21 48 21.659188 48 22.5 C 48 23.340812 47.340812 24 46.5 24 L 36 24 A 1.0001 1.0001 0 1 0 36 26 L 46.5 26 C 47.340812 26 48 26.659188 48 27.5 C 48 28.340812 47.340812 29 46.5 29 L 36 29 A 1.0001 1.0001 0 1 0 36 31 L 46.5 31 C 48.421188 31 50 29.421188 50 27.5 C 50 26.523075 49.58945 25.637295 48.935547 25 C 49.58945 24.362705 50 23.476925 50 22.5 C 50 20.578812 48.421188 19 46.5 19 L 36 19 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,6 @@
|
||||
<c-vars title="Playstation 4" />
|
||||
<c-svg :title=title viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 1 19 A 1.0001 1.0001 0 1 0 1 21 L 12.5 21 C 13.340812 21 14 21.659188 14 22.5 C 14 23.340812 13.340812 24 12.5 24 L 3 24 C 1.3550302 24 0 25.35503 0 27 L 0 30 A 1.0001 1.0001 0 1 0 2 30 L 2 27 C 2 26.43497 2.4349698 26 3 26 L 12.5 26 C 14.28508 26 15.719786 24.619005 15.921875 22.884766 A 1.0001 1.0001 0 0 0 16 22.5 C 16 20.578812 14.421188 19 12.5 19 L 1 19 z M 26 19 C 24.35503 19 23 20.35503 23 22 L 23 28 C 23 28.56503 22.56503 29 22 29 L 16 29 A 1.0001 1.0001 0 1 0 16 31 L 22 31 C 23.64497 31 25 29.64497 25 28 L 25 22 C 25 21.43497 25.43497 21 26 21 L 32 21 A 1.0001 1.0001 0 1 0 32 19 L 26 19 z M 46.970703 19 A 1.0001 1.0001 0 0 0 46.503906 19.130859 L 32.503906 27.130859 A 1.0001 1.0001 0 0 0 33 29 L 46 29 L 46 30 A 1.0001 1.0001 0 1 0 48 30 L 48 29 L 49 29 A 1.0001 1.0001 0 1 0 49 27 L 48 27 L 48 20 A 1.0001 1.0001 0 0 0 46.970703 19 z M 46 21.724609 L 46 27 L 36.767578 27 L 46 21.724609 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Playstation 5" viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M25.185 19.606c-1.612 0-2.919 1.307-2.919 2.919v4.981c0 .911-.739 1.65-1.65 1.65h-5.619v1.237h6.683c1.612 0 2.919-1.307 2.919-2.919v-4.981c0-.911.739-1.65 1.65-1.65l5.724 0v-1.237H25.185zM0 19.606v1.237h11.738c.936 0 1.694.758 1.694 1.694 0 .936-.758 1.694-1.694 1.694H2.919C1.307 24.231 0 25.538 0 27.15v3.244h2.333v-3.276c0-.911.739-1.65 1.65-1.65h8.851c1.619 0 2.931-1.312 2.931-2.931 0-1.619-1.312-2.931-2.931-2.931H0zM34.221 19.606v4.028c0 1.012.821 1.833 1.833 1.833h9.768c1.019 0 1.845.826 1.845 1.845 0 1.019-.826 1.845-1.845 1.845H34.221v1.237h12.697c1.702 0 3.082-1.38 3.082-3.082 0-1.702-1.38-3.082-3.082-3.082h-9.628c-.407 0-.737-.33-.737-.737v-2.651h13.023v-1.237H34.221z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Steam" viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 25 3 C 13.59 3 4.209375 11.680781 3.109375 22.800781 L 14.300781 28.529297 C 15.430781 27.579297 16.9 27 18.5 27 L 18.550781 27 C 18.940781 26.4 19.389375 25.649141 19.859375 24.869141 C 20.839375 23.259141 21.939531 21.439062 23.019531 20.039062 C 23.259531 15.569063 26.97 12 31.5 12 C 36.19 12 40 15.81 40 20.5 C 40 25.03 36.430937 28.740469 31.960938 28.980469 C 30.560938 30.060469 28.750859 31.160859 27.130859 32.130859 C 26.350859 32.610859 25.6 33.059219 25 33.449219 L 25 33.5 C 25 37.09 22.09 40 18.5 40 C 14.91 40 12 37.09 12 33.5 C 12 33.33 12.009531 33.17 12.019531 33 L 3.2792969 28.519531 C 4.9692969 38.999531 14.05 47 25 47 C 37.15 47 47 37.15 47 25 C 47 12.85 37.15 3 25 3 z M 31.5 14 C 27.92 14 25 16.92 25 20.5 C 25 24.08 27.92 27 31.5 27 C 35.08 27 38 24.08 38 20.5 C 38 16.92 35.08 14 31.5 14 z M 31.5 16 C 33.99 16 36 18.01 36 20.5 C 36 22.99 33.99 25 31.5 25 C 29.01 25 27 22.99 27 20.5 C 27 18.01 29.01 16 31.5 16 z M 18.5 29 C 17.71 29 16.960313 29.200312 16.320312 29.570312 L 19.640625 31.269531 C 20.870625 31.899531 21.350469 33.410625 20.730469 34.640625 C 20.280469 35.500625 19.41 36 18.5 36 C 18.11 36 17.729375 35.910469 17.359375 35.730469 L 14.029297 34.019531 C 14.289297 36.259531 16.19 38 18.5 38 C 20.99 38 23 35.99 23 33.5 C 23 31.01 20.99 29 18.5 29 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Ubisoft" viewbox="0 0 50 50">
|
||||
<c-slot name="path">
|
||||
M 19.5 0 C 17.570313 0 16 1.570313 16 3.5 C 16 5.429688 17.570313 7 19.5 7 C 21.429688 7 23 5.429688 23 3.5 C 23 1.570313 21.429688 0 19.5 0 Z M 7.59375 2 C 3.261719 2 0 5.550781 0 10.25 C 0 14.527344 4.402344 16.5 7.375 16.5 C 10.441406 16.5 15 14.429688 15 8.65625 C 15 3.398438 10.152344 2 7.59375 2 Z M 31.3125 5.03125 C 18.890625 5.03125 8.8125 16.082031 8.8125 29.65625 C 8.8125 41.664063 23.785156 49 31.9375 49 C 40.46875 49 50 38.972656 50 25.53125 C 50 12.53125 35.816406 5.03125 31.3125 5.03125 Z M 18 19 L 23 19 L 23 30.5 C 23 31.328125 23.671875 32 24.5 32 L 33.5 32 C 34.328125 32 35 31.328125 35 30.5 L 35 19 L 40 19 L 40 37 L 24.5 37 C 20.910156 37 18 34.089844 18 30.5 Z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,8 @@
|
||||
<c-svg viewbox="0 0 50 50">
|
||||
<g transform="scale(0.390625)">
|
||||
<title>Unspecified platform</title>
|
||||
<path fill="currentColor" d="M64.9,28.9c-5.2-0.2-10.1,1.6-13.9,5.2c-3.8,3.6-5.8,8.4-5.8,13.6h7.7c0-3.1,1.2-5.9,3.5-8.1c2.2-2.1,5.1-3.2,8.2-3.1 c5.7,0.3,10.3,4.9,10.6,10.6c0.3,6-5.7,11.5-8.3,13.6l-7.7,6.8v7.7h7.7V71l4.9-4.3c4.3-3.5,11.5-10.8,11.1-19.9 C82.4,37.2,74.5,29.3,64.9,28.9z" />
|
||||
<rect fill="currentColor" height="8" width="7.7" x="59.2" y="83.3" />
|
||||
<path fill="currentColor" d="M1,127h126V1H1V127z M9,9h110v110H9V9z" />
|
||||
</g>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg title="Xbox/GamePass" viewbox="0 0 30 30">
|
||||
<c-slot name="path">
|
||||
M 15 3 C 13.051 3 10.635984 3.6181719 8.8339844 4.7011719 C 8.7039844 4.7771719 8.3470625 5.0221406 8.1640625 5.2441406 L 8.1640625 5.2460938 C 9.8830625 3.3500938 14.893 7.3485937 15 7.4335938 L 15 7.4355469 C 15.107 7.3505469 20.116938 3.3520937 21.835938 5.2460938 C 21.651937 5.0240937 21.295063 4.780125 21.164062 4.703125 C 19.363063 3.622125 17.254953 3 15.001953 3 L 15 3 z M 7.4414062 6.1035156 C 7.0363594 6.1687656 6.5830625 6.4272031 6.1953125 6.8457031 C 4.2123125 8.9867031 3 11.850047 3 14.998047 C 3 18.106507 4.1826933 20.935533 6.1210938 23.066406 C 5.4850937 19.988406 6.0637812 17.819047 7.8007812 14.998047 C 9.5407813 12.174047 12.599609 8.9980469 12.599609 8.9980469 C 10.075609 6.6150469 8.2821719 6.1885156 7.8261719 6.1035156 C 7.7061719 6.0810156 7.5764219 6.0817656 7.4414062 6.1035156 z M 6.1210938 23.066406 C 6.1210938 23.066406 6.1210938 23.068359 6.1210938 23.068359 L 6.1210938 23.070312 C 8.3160938 25.482313 11.494953 27 15.001953 27 C 18.518953 27 21.684859 25.485219 23.880859 23.074219 L 23.880859 23.072266 C 25.818859 20.940266 27 18.109 27 15 C 27 11.852 25.788687 8.9896563 23.804688 6.8476562 C 23.287688 6.2896563 22.653828 6.0154687 22.173828 6.1054688 C 21.718828 6.1914688 19.924391 6.618 17.400391 9 C 17.400391 9 20.459219 12.176 22.199219 15 C 23.935219 17.822 24.514906 19.990359 23.878906 23.068359 C 23.872906 23.033359 23.629672 21.45375 22.013672 19.21875 C 20.750672 17.47575 17 13.300391 15 11.400391 C 13 13.300391 9.2493281 17.471797 7.9863281 19.216797 C 6.3703281 21.449797 6.1270937 23.030406 6.1210938 23.066406 z
|
||||
</c-slot>
|
||||
</c-svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
<c-svg viewbox="0 0 80 80" preserveAspectRatio="xMidYMid meet" stroke-width="3.6">
|
||||
<title>Yuzu (Switch emulator)</title>
|
||||
<path fill="currentColor" d="m30,2a28,30 0 1,0 0,60z" />
|
||||
<path fill="currentColor" d="m42,78a28,30 0 1,0 0-60z" />
|
||||
</c-svg>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@@ -0,0 +1,212 @@
|
||||
{% 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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<c-vars without_buttons="false" submit_text="Submit" close_text="Cancel" />
|
||||
<div id="modal-container">
|
||||
<div class="tt-modal fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||
<div class="relative mx-auto p-5 border-accent border w-full max-w-md shadow-lg/50 rounded-md bg-white dark:bg-gray-900">
|
||||
<div class="{{ container_class }}">
|
||||
{{ slot }}
|
||||
{% if not without_buttons %}
|
||||
<div class="items-center mt-5">
|
||||
<c-button color="blue" size="lg" type="submit" class="w-full">{{ submit_text }}</c-button>
|
||||
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('.tt-modal').remove()">{{ close_text }}</c-button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
|
||||
<div data-popover
|
||||
id="{{ id }}"
|
||||
role="tooltip"
|
||||
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
|
||||
<div class="px-3 py-2">{{ popover_content }}</div>
|
||||
<div data-popper-arrow></div>
|
||||
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
|
||||
<span class="hidden decoration-dotted"></span>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<span title="Price is a result of conversion and rounding." class="decoration-dotted underline">{{ slot }}</span>
|
||||
@@ -0,0 +1,25 @@
|
||||
<c-vars :name="id" />
|
||||
<!-- <div class="pb-4 bg-white dark:bg-gray-900">
|
||||
<label for="table-search" class="sr-only">Search</label>
|
||||
<div class="relative mt-1">
|
||||
<div class="absolute inset-y-3 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}">
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
|
||||
<form class="max-w-md mx-auto">
|
||||
<label for="search" class="block mb-2.5 text-sm font-medium text-heading sr-only ">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg>
|
||||
</div>
|
||||
<input type="search" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block w-full p-3 ps-9 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}" required />
|
||||
<button type="button" class="absolute end-1.5 bottom-1.5 text-white bg-brand hover:bg-brand-strong box-border border border-transparent focus:ring-4 focus:ring-brand-medium shadow-xs font-medium leading-5 rounded text-xs px-3 py-1.5 focus:outline-none cursor-pointer">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{% load param_utils %}
|
||||
<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 %}
|
||||
<c-table-header>
|
||||
{{ header_action }}
|
||||
</c-table-header>
|
||||
{% 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 %}<c-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>
|
||||