Compare commits

...

54 Commits

Author SHA1 Message Date
lukas ed8589a972 Fix more code smells
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s
2026-06-06 13:14:55 +02:00
lukas f4161bf3f4 Improve stats code smells 2026-06-06 12:19:15 +02:00
lukas b6864e59ce Add filters
Django CI/CD / test (push) Successful in 43s
Django CI/CD / build-and-push (push) Successful in 1m22s
2026-06-06 12:13:04 +02:00
lukas 36b1382015 Fix code smells 2026-06-06 08:15:19 +02:00
lukas d101aecd70 Move from HTML templates to pure Python
Remove cruft
2026-06-06 07:51:10 +02:00
lukas 09db54e940 Add new lint and format checks 2026-06-06 07:10:54 +02:00
lukas f090643026 Keep moving towards pure Python components 2026-06-02 22:35:11 +02:00
lukas ec1828b823 Migrate cotton to Python + template tag shims
Django CI/CD / test (push) Successful in 32s
Django CI/CD / build-and-push (push) Successful in 1m22s
2026-06-02 22:19:55 +02:00
lukas 94c3d9050a Fix make init 2026-06-02 16:32:26 +02:00
lukas ad47684dc1 Automatically escape text in components 2026-06-02 16:09:39 +02:00
lukas 66ec8e1eed Add CLAUDE.md
Django CI/CD / test (push) Successful in 42s
Django CI/CD / build-and-push (push) Successful in 1m48s
2026-06-02 15:08:24 +02:00
lukas 1583c474b2 Update README.md 2026-06-02 15:07:53 +02:00
lukas 2f433c92da Update uv.lock
Django CI/CD / test (push) Successful in 44s
Django CI/CD / build-and-push (push) Successful in 1m26s
2026-05-12 18:57:13 +02:00
lukas 5b2b79f553 Fix comment not being a comment 2026-05-12 18:56:58 +02:00
lukas 36411c99a7 Version 1.7.0
Django CI/CD / test (push) Successful in 38s
Django CI/CD / build-and-push (push) Has been skipped
## 1.7.0 / 2026-05-12

### New
* Add toast notification system with HTMX middleware integration
* Add component system (Cotton-based): button, modal, table_row,
search_field, gamelink
* Add needs_price_update field to Purchase model for reliable price
change detection
* Add confirmation dialog before deleting a game
* Add game status information documentation (STATUSES.md)
* Allow directly updating device in session list via inline selector
* Migrate from Poetry to uv for Python dependency management
* Scope URLs to the games namespace
* Start session template shared between add and edit views

### Improved
* Major style overhaul: CSS variables, improved dark mode, Flowbite 4.x
upgrade
* Improve game status evaluation and add abandon prompt on refund
* Robustify Docker container and fix default database location
* Make component rendering deterministic for improved caching
* Component caching: deterministic randomid generation
* Component test suite with 1000+ lines of tests
* Make tests more robust with django-pytest
* Update NameWithIcon component: testable, fixed platform extraction bug
* Pin Caddy version and improve make dev-prod
* Add .env.example documenting environment variables
* Unify A() component with explicit url_name vs href parameters

### Fixed
* Fix refund confirmation not working
* Fix stats view missing first and last game values
* Fix A() component silent fallback on URL typos
* Fix secondary submit buttons not working
* Fix button not passing attributes
* Fix default mutable arguments in component functions
* Fix extra submit button when adding purchase
* Fix pointer cursor on search field button

### Removed
* Remove GraphQL API

### Dependencies
* Update django-ninja to 1.6.2
2026-05-12 18:36:46 +02:00
lukas 360e8f9eaf Make container more robust (#95)
Django CI/CD / build-and-push (push) Has been cancelled
Django CI/CD / test (push) Has been cancelled
Reviewed-on: #95

12 files changed (+149, -66)
Key changes:
1. Monolithic container — Replaced the two-service compose setup (backend + frontend/caddy) with a single timetracker container. Caddy is now built into the image rather than running as a separate container.
2. Supervisord process manager — Added supervisor.conf and installed supervisor in the Dockerfile. entrypoint.sh now delegates to supervisord to manage three processes: Caddy, Gunicorn, and Qcluster — replacing manual trap/signaling logic.
3. Bundled Caddy — The Dockerfile now downloads and installs Caddy v2.9.1 directly into the image (/usr/local/bin/caddy). The Caddyfile was updated to use reverse_proxy localhost:8001 and serves static files from /home/timetracker/app/static.
4. Configurable deployment — Added .env.example with configurable environment variables: TZ, PUID/PGID, TIMETRACKER_EXTERNAL_PORT, DATA_DIR, CSRF_TRUSTED_ORIGINS. docker-compose.yml now references these with sensible defaults.
5. UID/GID flexibility — entrypoint.sh uses usermod/groupmod at startup to remap the timetracker user to the host-specified PUID/PGID, avoiding permission issues with mounted volumes.
6. Database & static files — settings.py now respects DATA_DIR env var for the SQLite database path. STATIC_ROOT changed to BASE_DIR / "static".
7. Dev improvements — New Caddyfile.dev (with browse enabled for static files) and updated Makefile dev-prod target runs Caddy alongside Django in development.
8. Tests — Re-enabled the test step in the Docker build GitHub Actions workflow.
2026-05-12 16:29:34 +00:00
lukas c10b7a8013 Improve make dev-prod
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m23s
2026-05-12 15:27:56 +02:00
lukas 103c29e234 Fix missing values for first and last game in stats view
Django CI/CD / test (push) Successful in 22s
Django CI/CD / build-and-push (push) Successful in 53s
2026-05-12 15:12:43 +02:00
lukas 5003b739d3 PR review
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 55s
2026-05-12 14:56:59 +02:00
lukas 4ba3ed555f Add info on statuses 2026-05-12 14:51:59 +02:00
lukas e3b53cd4a9 Add needs_price_update field to Purchase model
Django CI/CD / test (push) Successful in 22s
Django CI/CD / build-and-push (push) Has been skipped
Replace fragile price change detection in Purchase.save() with a
lazy dirty flag approach. A pre_save/post_save signal pair detects
price/currency changes without extra DB queries, and convert_prices()
uses the flag to determine which purchases need conversion.

- Add needs_price_update BooleanField with db_index
- Add pre_save signal to store old price/currency values
- Add post_save signal to set needs_price_update=True when price/currency changes
- Simplify Purchase.save() to remove DB reload + comparison logic
- Remove price_or_currency_differ_from() method
- Update convert_prices() to filter on needs_price_update flag
- Extract _get_exchange_rate() and _save_converted_price() helpers
- Add tests for the new behavior
2026-05-12 13:57:59 +02:00
lukas a4e697a274 Add confirmation before deleting game
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 1m1s
2026-05-12 13:37:55 +02:00
lukas b8187c32b1 Always abandon refunded games
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 54s
2026-05-12 12:49:07 +02:00
lukas bf2b86ba1f Streamline evaluating game status 2026-05-12 12:48:14 +02:00
lukas 913c7d3a98 Scope URLs to the games namespace 2026-05-12 12:43:08 +02:00
lukas 37e3c69abc Make tests more robust, use django-pytest 2026-05-12 11:56:28 +02:00
lukas 0866eb25e9 update django-ninja to 1.6.2 2026-05-12 11:15:07 +02:00
lukas 39f21bc7db Remove GraphQL API 2026-05-12 11:15:07 +02:00
lukas 1416d00a37 Fix additional tests 2026-05-12 11:15:07 +02:00
lukas d9fe99963a Fix htmx_middleware tests 2026-05-12 11:01:48 +02:00
lukas 393476be85 Fix test_duration_format 2026-05-12 10:48:30 +02:00
lukas e32af2f576 Fix test_paths_return_200 2026-05-12 10:43:38 +02:00
lukas e565002244 Add simple table rendering tests
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m6s
2026-05-12 10:21:33 +02:00
lukas 1a4e51c95a Update NameWithIcon
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.
2026-05-12 10:05:15 +02:00
lukas eae020fd34 Add component tests 2026-05-12 09:43:45 +02:00
lukas 1f4dd60c54 Fix default mutable arguments
`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.
2026-05-12 09:39:43 +02:00
lukas 656a96f55c Fix A() component
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Fixes:
- Silent fallback (typos like `"ad_puchase"` silently became broken links) → now raises `NoReverseMatch` at render time
- `type(url) is str` gate → removed (implicit dual-mode eliminated entirely)
- Callable parameter (`url: Callable`) dead code → removed
- Implicit dual-mode (`url="name"` vs `url=reverse("name")`) → `url_name` vs `href` are now mutually exclusive params
- Inconsistent type annotation mixing `Callable` with string default → cleaned up
- Added `ValueError` when both `url_name` and `href` are provided
- Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`)
2026-05-12 09:01:05 +02:00
lukas 8c3e819a5f Consistent component return type 2026-05-12 08:43:39 +02:00
lukas ff11e35115 Add component tests 2026-05-12 08:31:17 +02:00
lukas ebef0bba87 Make randomid deterministic to improve caching 2026-05-12 08:27:11 +02:00
lukas 140f3d2bd6 Add caching tests 2026-05-12 08:21:48 +02:00
lukas 245a4f5b3e Add component improvement doc 2026-05-12 08:10:46 +02:00
lukas cd9f0b4111 Caching 1/? 2026-05-12 08:10:33 +02:00
lukas f82c61ef1e Add toast notification system
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 54s
Add more toast types
2026-05-11 20:22:23 +02:00
lukas 4e3b0ddb08 Allow directly updating device in session list
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m12s
2026-05-11 12:54:42 +02:00
lukas a549050860 Make edit_session use the same template as add_session
Django CI/CD / test (push) Successful in 34s
Django CI/CD / build-and-push (push) Successful in 1m36s
2026-05-06 10:43:57 +02:00
lukas 596d1ccfe1 Fix refund confirmation not working
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m34s
2026-03-05 20:34:58 +01:00
lukas bb26fec5e3 Fix extra submit button when adding purchase
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 1m13s
2026-02-25 08:04:48 +01:00
lukas 1ba7de0bb7 Use pointer cursor for search field button
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m2s
2026-02-21 21:50:46 +01:00
lukas 3391fb72f2 Fix secondary submit buttons not working 2026-02-21 21:48:31 +01:00
lukas 0986e59fe7 Improve styles
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m4s
2026-02-18 23:30:30 +01:00
lukas 46b1199863 Fix button not passing attributes 2026-02-18 23:30:12 +01:00
lukas bc1092b0b3 Add prompt to set game to Abandoned upon refund
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 1m55s
2026-02-17 22:14:36 +01:00
lukas 996c0107c9 Housekeeping
* Updated flowbite to 4.x
* Start revamping styles
* Remove unused GraphQL code
* Make some templates more robuts
2026-02-17 22:14:16 +01:00
195 changed files with 14422 additions and 4355 deletions
-1
View File
@@ -9,7 +9,6 @@ static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
+21
View File
@@ -0,0 +1,21 @@
# Docker registry URL (used in docker-compose.yml)
REGISTRY_URL=registry.kucharczyk.xyz
# Container timezone
TZ=Europe/Prague
# User/group IDs for container (used in entrypoint.sh)
PUID=1000
PGID=100
# External port mapping
TIMETRACKER_EXTERNAL_PORT=8000
# Django production mode (set to "1" for production)
PROD=1
# Database directory (defaults to project root)
DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
+3 -3
View File
@@ -22,8 +22,8 @@ jobs:
- name: Run Migrations
run: uv run python manage.py migrate
# - name: Run Tests
# run: PROD=1 uv run pytest
- name: Run Tests
run: uv run --with pytest-django pytest
build-and-push:
needs: test
@@ -33,7 +33,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+2
View File
@@ -4,7 +4,9 @@ __pycache__
.venv/
node_modules
package-lock.json
pnpm-lock.yaml
db.sqlite3
data/
/static/
dist/
.DS_Store
+43 -1
View File
@@ -1,3 +1,45 @@
## 1.7.0 / 2026-05-12
### New
* Add toast notification system with HTMX middleware integration
* Add component system (Cotton-based): button, modal, table_row, search_field, gamelink
* Add needs_price_update field to Purchase model for reliable price change detection
* Add confirmation dialog before deleting a game
* Add game status information documentation (STATUSES.md)
* Allow directly updating device in session list via inline selector
* Migrate from Poetry to uv for Python dependency management
* Scope URLs to the games namespace
* Start session template shared between add and edit views
### Improved
* Major style overhaul: CSS variables, improved dark mode, Flowbite 4.x upgrade
* Improve game status evaluation and add abandon prompt on refund
* Robustify Docker container and fix default database location
* Make component rendering deterministic for improved caching
* Component caching: deterministic randomid generation
* Component test suite with 1000+ lines of tests
* Make tests more robust with django-pytest
* Update NameWithIcon component: testable, fixed platform extraction bug
* Pin Caddy version and improve make dev-prod
* Add .env.example documenting environment variables
* Unify A() component with explicit url_name vs href parameters
### Fixed
* Fix refund confirmation not working
* Fix stats view missing first and last game values
* Fix A() component silent fallback on URL typos
* Fix secondary submit buttons not working
* Fix button not passing attributes
* Fix default mutable arguments in component functions
* Fix extra submit button when adding purchase
* Fix pointer cursor on search field button
### Removed
* Remove GraphQL API
### Dependencies
* Update django-ninja to 1.6.2
## 1.6.1 / 2026-01-30 11:48+01:00
### New
@@ -156,7 +198,7 @@
* Use the same form when editing a session as when adding a session
* Change recent session view to current year instead of last 30 days
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
### Fixes
+86
View File
@@ -0,0 +1,86 @@
# 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) |
| 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) |
## Architecture
A Django 6+ monolith with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a custom server-side component system, plus a Django Ninja REST API.
### Directory layout
```
games/ — Django app: models, views, templates, forms, signals, tasks, API
common/ — Shared utilities: time formatting, component system, HTML helpers
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
contrib/ — One-off scripts (exchange rate import)
```
### Models (in `games/models.py`)
- **Game** — name, platform, status (Unplayed/Played/Finished/Retired/Abandoned), mastered, playtime
- **Platform** — name, group, icon slug
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a GeneratedField), links to Game via M2M
- **Session** — start/end timestamps, manual duration, device. `duration_calculated` and `duration_total` are GeneratedFields (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
### Key patterns
**Component system** (`common/components.py`): Python functions return HTML via django-cotton templates. Every component wraps `Component()` which calls `render_to_string` (LRU-cached in production). Key helpers: `A()`, `Button()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `NameWithIcon()`, `LinkedPurchase()`, `Div()`, `Form()`.
**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`, `general.py`. The `general.py` has two context processors: `model_counts` and `global_current_year`.
**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
- `post_save/post_delete` on Session: recalculates Game.playtime
- `pre_save` on Game: creates GameStatusChange audit records
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates 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.
**REST API** (`games/api.py`): Django Ninja with routers for playevents, games, and sessions. Game status and session device can be PATCHed via the API.
### Templates
Templates live in `games/templates/`. The layout uses django-cotton components in `templates/cotton/` — a reusable component library with `button.html`, `table.html`, `popover.html`, etc. Platform icons are stored as individual HTML snippet files under `cotton/icon/<slug>.html`. Partials for HTMX responses are in `templates/partials/`.
### 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 Drone (`.drone.yml`): runs tests, builds Docker image, deploys 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 GeneratedFields 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
- Django Admin and Debug Toolbar are only available in DEBUG mode
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
+12 -11
View File
@@ -1,14 +1,15 @@
{
auto_https off
admin off
{
auto_https off
}
:8000 {
handle_path /static/* {
root * /usr/share/caddy
file_server
}
handle {
reverse_proxy backend:8001
}
}
handle_path /static/* {
root * /home/timetracker/app/static
file_server
}
handle /robots.txt {
root * /home/timetracker/app/games/static
file_server
}
reverse_proxy localhost:8001
}
+15
View File
@@ -0,0 +1,15 @@
{
auto_https off
}
:8000 {
handle_path /static/* {
root * static
file_server browse
}
handle /robots.txt {
root * games/static
file_server browse
}
reverse_proxy :8001
}
+22 -8
View File
@@ -22,20 +22,34 @@ ENV PROD=1 \
PYTHONUNBUFFERED=1 \
PATH="/home/timetracker/app/.venv/bin:$PATH"
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p /var/www/django/static \
&& chown timetracker:timetracker /var/www/django/static
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libcap2-bin \
supervisor \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m --uid 1000 timetracker \
&& mkdir -p /var/log/supervisor /etc/supervisor/conf.d /home/timetracker/data \
&& chown timetracker:timetracker /var/log/supervisor /home/timetracker/data
ARG CADDY_VERSION=2.9.1
RUN curl -sL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
-o /tmp/caddy.tar.gz && \
tar -xzf /tmp/caddy.tar.gz -C /tmp && \
mv /tmp/caddy /usr/local/bin/caddy && \
rm /tmp/caddy.tar.gz && \
chmod +x /usr/local/bin/caddy
WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
COPY --chown=timetracker:timetracker entrypoint.sh /
RUN chmod +x /entrypoint.sh
USER timetracker
ENV VERSION_NUMBER=1.6.1
ENV VERSION_NUMBER=1.7.0
EXPOSE 8000
CMD [ "/entrypoint.sh" ]
ENTRYPOINT ["/entrypoint.sh"]
+27 -15
View File
@@ -1,15 +1,14 @@
all: css migrate
initialize: npm css migrate sethookdir loadplatforms
initialize: npm css migrate loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm:
npm install
pnpm install
css: common/input.css
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
makemigrations:
uv run python manage.py makemigrations
@@ -18,29 +17,28 @@ migrate: makemigrations
uv run python manage.py migrate
init:
uv install $(PYTHON_VERSION)
uv python install $(PYTHON_VERSION)
uv sync
npm install
$(MAKE) sethookdir
pnpm install
$(MAKE) loadplatforms
sethookdir:
git config core.hooksPath .githooks
chmod +x .githooks/*
dev:
@npx concurrently \
@pnpm concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
caddy:
caddy run --watch
dev-prod: migrate collectstatic
PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
@npx concurrently \
--names "Caddy,Django,Django-Q" \
"caddy run --config Caddyfile.dev" \
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker" \
"PROD=1 uv run manage.py qcluster"
dumpgames:
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
@@ -67,7 +65,21 @@ uv.lock: pyproject.toml
uv sync
test: uv.lock
uv run pytest
uv run --with pytest-django pytest
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=" "))'
+3 -3
View File
@@ -4,12 +4,12 @@ A simple game catalogue and play session tracker.
# Development
The project uses `pyenv` to manage installed Python versions.
If you have `pyenv` installed, you can simply run:
The project uses `uv` to manage Python versions and dependencies.
Simply run:
```
make init
```
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
This installs the correct Python version, syncs all dependencies, and installs npm packages.
Afterwards, you can start the development server using `make dev`.
+157
View File
@@ -0,0 +1,157 @@
# Game & Purchase Status Definitions
## Game Statuses
Games have a `status` field with the following values:
| Status | Code | Description |
|--------|------|-------------|
| **Unplayed** | `u` | Game was purchased but never played |
| **Played** | `p` | Game was played but not yet finished |
| **Finished** | `f` | Game has been completed |
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
| **Abandoned** | `a` | Game was played but the user gave up on it |
**Setting game status:**
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
- Status changes are tracked in `GameStatusChange` model
- Refunding a purchase always marks its games as abandoned
---
## Purchase-Level Status Concepts
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
### Finished
A purchase is considered **finished** when:
```
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
```
Either signal indicates the game is complete:
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
### Dropped
A purchase is considered **dropped** when:
```
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
```
Either signal indicates the user no longer has an active interest in the game:
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
---
## Unfinished vs. Dropped
The stats views categorize purchases into **unfinished** and **dropped** lists.
### Unfinished
A purchase is **unfinished** when:
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
2. It was NOT refunded (only counts toward unfinished/backlog)
3. It is NOT finished (per the finished definition above)
4. It is NOT dropped (per the dropped definition above)
5. It is NOT infinite (subscription, etc.)
6. It IS a game or DLC (not season passes or battle passes)
**Unfinished = Active backlog** — games the user may still play.
### Dropped
A purchase is **dropped** when:
1. It was purchased in the relevant time period
2. It is NOT finished (per the finished definition above)
3. It matches at least one dropped signal (per the dropped definition above)
4. It is NOT infinite
5. It IS a game or DLC
**Dropped = Terminal state** — games the user has given up on or refunded.
### Summary Table
| Category | Includes Refunded? | Key Condition |
|----------|-------------------|---------------|
| **Unfinished** | No | NOT finished, NOT dropped |
| **Dropped** | Yes | Finished OR Abandoned/Retired |
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
| **Infinite** | Yes | `infinite = True` |
---
## Query Patterns
### Checking if a game is finished
```python
game.finished() # Returns True if status="f" or has PlayEvent with ended date
```
### Checking if a game is abandoned
```python
game.abandoned() # Returns True if status="a"
```
### Getting finished purchases
```python
Purchase.objects.finished() # All purchases where games are finished
```
### Getting dropped purchases
```python
Purchase.objects.dropped() # All purchases that are abandoned or refunded
```
---
## Transition State
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
- **Finished**: `status="f" OR PlayEvent.ended`
- **Dropped**: `status="a" OR date_refunded`
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
**Future:** These signals should be kept in sync. For example:
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
- When the sync is reliable, the OR can be simplified to a single check
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
---
## Edge Cases
### Unplayed games
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
- They appear in the unfinished/backlog list since they are still games the user may play
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
### Multiple games per purchase
- A purchase can have multiple games via `Purchase.games` (many-to-many)
- A purchase is finished if ANY of its games is finished
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
### PlayEvents without ended date
- A PlayEvent with `started` but no `ended` does NOT count as finished
- This represents a game that was started but not completed
### Retired games
- Retired games (`status="r"`) are considered **dropped**
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
+1950
View File
File diff suppressed because it is too large Load Diff
-293
View File
@@ -1,293 +0,0 @@
from random import choices as random_choices
from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, 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 Component(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
template: str = "",
tag_name: str = "",
) -> HTMLTag:
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 != "":
tag = render_to_string(
template,
{name: value for name, value in attributes}
| {"slot": mark_safe("\n".join(children))},
)
return mark_safe(tag)
def randomid(seed: str = "", length: int = 10) -> str:
return seed + "".join(random_choices(ascii_lowercase, k=length))
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
id = randomid()
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] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
):
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
"""
additional_attributes = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
size: str = "base",
icon: bool = False,
color: str = "blue",
):
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] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
):
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("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 mark_safe(A(url=link, children=[a_content]))
def NameWithIcon(
name: str = "",
platform: str = "",
game_id: int = 0,
session_id: int = 0,
purchase_id: int = 0,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
create_link = False
link = ""
platform = None
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = name or game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
)
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if emulated else "",
PopoverTruncated(name),
],
)
return mark_safe(
A(
url=link,
children=[content],
)
if create_link
else content,
)
def PurchasePrice(purchase) -> str:
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",
)
+94
View File
@@ -0,0 +1,94 @@
"""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,
H1,
Icon,
Input,
Modal,
ModuleScript,
Popover,
PopoverTruncated,
SearchField,
SimpleTable,
TableHeader,
TableRow,
TableTd,
paginated_table_content,
)
from common.components.domain import (
GameLink,
GameStatus,
GameStatusSelector,
LinkedPurchase,
NameWithIcon,
PriceConverted,
PurchasePrice,
SessionDeviceSelector,
_resolve_name_with_icon,
)
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SelectableFilter,
SessionFilterBar,
)
__all__ = [
"truncate",
"Component",
"HTMLAttribute",
"HTMLTag",
"_render_element",
"randomid",
"A",
"AddForm",
"Button",
"ButtonGroup",
"CsrfInput",
"Div",
"H1",
"Icon",
"Input",
"Modal",
"ModuleScript",
"Popover",
"PopoverTruncated",
"SearchField",
"SimpleTable",
"TableHeader",
"TableRow",
"TableTd",
"paginated_table_content",
"GameLink",
"GameStatus",
"GameStatusSelector",
"LinkedPurchase",
"NameWithIcon",
"PriceConverted",
"PurchasePrice",
"SessionDeviceSelector",
"_resolve_name_with_icon",
"FilterBar",
"PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar",
]
+74
View File
@@ -0,0 +1,74 @@
"""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
+345
View File
@@ -0,0 +1,345 @@
"""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,
)
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 Component(
tag_name="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 = Component(
tag_name="span",
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
children=["\xa0"],
)
return Component(
tag_name="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 Component(
tag_name="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>"
)
+782
View File
@@ -0,0 +1,782 @@
"""Stash-style filter bars and the SelectableFilter widget."""
from django.db import models
from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
_FILTER_INPUT_CLASS = (
"block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 "
"focus:ring-brand focus:border-brand"
)
_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 _filter_get_choice(existing: dict, field: str) -> tuple[list[str], list[str], str]:
raw = existing.get(field, {})
if not isinstance(raw, dict):
return [], [], ""
val = raw.get("value", [])
excl = raw.get("excludes", [])
mod = raw.get("modifier", "")
if isinstance(val, str):
val = [val]
if isinstance(excl, str):
excl = [excl]
return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or ""
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=[
Component(
tag_name="label",
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
widget,
],
)
def _filter_number(label, name, value="", placeholder="") -> SafeText:
return _filter_field(
label,
Component(
tag_name="input",
attributes=[
("type", "number"),
("name", escape(name)),
("id", escape(name)),
("value", escape(value)),
("placeholder", escape(placeholder)),
("class", _FILTER_INPUT_CLASS),
],
),
)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
return Component(
tag_name="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,
],
)
def _filter_range_inputs(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"):
"""Twin <input type=range> slider (used by the game filter bar)."""
mv = min_v or str(dmin)
xv = max_v or str(dmax)
return Component(
tag_name="div",
attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")],
children=[
mark_safe(
f'<input type="range" class="range-min absolute w-full pointer-events-none '
f"appearance-none bg-transparent h-2 "
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
f'data-target="{min_id}" data-peer="{max_id}" '
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">'
f'<input type="range" class="range-max absolute w-full pointer-events-none '
f"appearance-none bg-transparent h-2 "
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
f'data-target="{max_id}" data-peer="{min_id}" '
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
),
],
)
def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"):
"""Handle-based slider (used by the session & purchase filter bars)."""
return Component(
tag_name="div",
attributes=[
("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"),
("data-min", str(lo)),
("data-max", str(hi)),
("data-step", str(step)),
],
children=[
mark_safe(
'<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'
+ f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{min_id}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{max_id}" style="left:100%"></div>'
),
],
)
_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"],
),
Component(
tag_name="span",
attributes=[
("class", "flex gap-2 items-center"),
("id", "save-preset-area"),
],
children=[
Component(
tag_name="input",
attributes=[
("type", "text"),
("id", "preset-name-input"),
("placeholder", "Preset name..."),
(
"class",
"hidden px-3 py-2 text-sm rounded-lg border "
"border-default-medium bg-neutral-secondary-medium "
"text-heading focus:ring-brand focus:border-brand",
),
],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("id", "save-preset-btn"),
("onclick", "showPresetNameInput()"),
(
"class",
"px-4 py-2 text-sm font-medium text-gray-900 "
"bg-white border border-gray-200 rounded-lg "
"hover:bg-gray-100 dark:bg-gray-800 "
"dark:border-gray-600 dark:text-gray-400 "
"dark:hover:bg-gray-700 dark:hover:text-white",
),
],
children=["Save Preset"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("id", "confirm-save-preset-btn"),
(
"onclick",
f"savePreset('{_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=[
Component(
tag_name="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[tuple[str, str]] | None = None,
platform_options: list[tuple[int, str]] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> SafeText:
"""Collapsible filter bar for the Game list."""
from games.models import Game, Platform
if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status]
if platform_options is None:
platform_options = list(
Platform.objects.order_by("name").values_list("id", "name")
)
existing = _filter_parse(filter_json)
status_sel, status_excl, status_mod = _filter_get_choice(existing, "status")
plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform")
plat_opts_str = [(str(k), v) for k, v in platform_options]
year_rel = existing.get("year_released", {})
year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else ""
year_max = str(year_rel.get("value2", "")) if isinstance(year_rel, dict) else ""
mastered_val = (
existing.get("mastered", {}).get("value", False)
if isinstance(existing.get("mastered"), dict)
else False
)
playtime = existing.get("playtime_minutes", {})
playtime_min = (
_filter_mins_to_hrs(playtime.get("value", ""))
if isinstance(playtime, dict)
else ""
)
playtime_max = (
_filter_mins_to_hrs(playtime.get("value2", ""))
if isinstance(playtime, dict)
else ""
)
try:
year_agg = Game.objects.aggregate(
yr_min=models.Min("year_released"), yr_max=models.Max("year_released")
)
except Exception:
year_agg = {}
try:
pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime"))
except Exception:
pt_agg = {}
yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970)
yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030)
pt_data_max = (
int((pt_agg.get("pt_max") or 0).total_seconds() / 3600)
if pt_agg.get("pt_max")
else 200
)
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
SelectableFilter(
"status",
status_options,
status_sel,
status_excl,
status_mod,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
SelectableFilter(
"platform",
plat_opts_str,
plat_sel,
plat_excl,
plat_mod,
nullable=Game._meta.get_field("platform").null,
),
),
_filter_number("Year Min", "filter-year-min", year_min, "e.g. 2020"),
_filter_number("Year Max", "filter-year-max", year_max, "e.g. 2024"),
],
),
_filter_range_inputs(
"year-range",
"filter-year-min",
"filter-year-max",
year_min,
year_max,
yr_data_min,
yr_data_max,
),
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_number(
"Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1"
),
_filter_number(
"Playtime Max (hrs)",
"filter-playtime-max",
playtime_max,
"e.g. 100",
),
Component(
tag_name="div",
attributes=[("class", "flex items-end pb-1")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_val)
],
),
],
),
_filter_range_inputs(
"playtime-range",
"filter-playtime-min",
"filter-playtime-max",
playtime_min or "0",
playtime_max or str(pt_data_max),
0,
pt_data_max,
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def SelectableFilter(
field_name: str,
options: list[tuple[str, str]],
selected: list[str] | None = None,
excluded: list[str] | None = None,
modifier: str = "",
nullable: bool = True,
) -> "SafeText":
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
selected = selected or []
excluded = excluded or []
active_mod_html = ""
inactive_mod_html = ""
mod_opts = [("NOT_NULL", "(Any)")]
if nullable:
mod_opts.append(("IS_NULL", "(None)"))
for mod_val, mod_label in mod_opts:
if modifier == mod_val:
active_mod_html = (
f'<span class="sf-modifier-tag active" data-modifier="{mod_val}">'
f"{mod_label}</span> "
)
else:
inactive_mod_html += (
f'<div class="sf-option sf-modifier-option" data-modifier="{mod_val}" '
f'data-label="{mod_label}">'
f'<span class="sf-option-label">{mod_label}</span></div>'
)
selected_html = ""
for val in selected:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag" data-value="{escape(val)}" data-type="include">'
f'<span class="sf-tag-text text-body">\u2713 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
for val in excluded:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag sf-excluded" data-value="{escape(val)}" data-type="exclude">'
f'<span class="sf-tag-text text-body">\u2717 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
options_html = ""
for val, label in options:
options_html += (
f'<div class="sf-option" data-value="{escape(val)}" data-label="{escape(label)}">'
f'<span class="sf-option-label">{escape(label)}</span>'
f'<span class="sf-option-buttons">'
f'<button type="button" class="sf-btn-include" data-action="include" title="Include">+</button>'
f'<button type="button" class="sf-btn-exclude" data-action="exclude" title="Exclude">\u2212</button>'
f"</span></div>"
)
return Component(
tag_name="div",
attributes=[
(
"class",
"sf-container border border-default-medium rounded-base bg-neutral-secondary-medium",
),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(
tag_name="div",
attributes=[
("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
],
children=[mark_safe(active_mod_html + selected_html)],
),
Component(
tag_name="input",
attributes=[
("type", "text"),
(
"class",
"sf-search block w-full border-0 border-t border-default-medium "
"bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden",
),
("placeholder", "Search\u2026"),
],
),
Component(
tag_name="div",
attributes=[
("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"),
],
children=[mark_safe(inactive_mod_html + options_html)],
),
],
)
def _find_label(options: list[tuple[str, str]], value: str) -> str:
for v, label in options:
if str(v) == str(value):
return label
return value
def SessionFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Session list."""
from games.models import Device, Game, Session
game_opts = [
(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
]
dev_opts = [
(str(k), v)
for k, v in Device.objects.order_by("name").values_list("id", "name")
]
existing = _filter_parse(filter_json)
gs, ge, gm = _filter_get_choice(existing, "game")
ds, de, dm = _filter_get_choice(existing, "device")
dur = existing.get("duration_minutes", {})
dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else ""
dmax = _filter_mins_to_hrs(dur.get("value2", "")) if isinstance(dur, dict) else ""
em = (
existing.get("emulated", {}).get("value", False)
if isinstance(existing.get("emulated"), dict)
else False
)
ac = (
existing.get("is_active", {}).get("value", False)
if isinstance(existing.get("is_active"), dict)
else False
)
try:
a = Session.objects.aggregate(m=models.Max("duration_total"))
ddm = max(
int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1
)
except Exception:
ddm = 200
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
SelectableFilter(
"game",
game_opts,
gs,
ge,
gm,
nullable=not Game._meta.get_field("name").has_default(),
),
),
_filter_field(
"Device",
SelectableFilter(
"device",
dev_opts,
ds,
de,
dm,
nullable=Session._meta.get_field("device").null,
),
),
_filter_number(
"Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5"
),
_filter_number(
"Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10"
),
],
),
_filter_range_handles(
"dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm
),
Component(
tag_name="div",
attributes=[("class", "flex gap-4 mb-4")],
children=[
_filter_checkbox("filter-emulated", "Emulated", em),
_filter_checkbox("filter-active", "Active", ac),
],
),
]
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 Game, Platform, Purchase
game_opts = [
(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
]
plat_opts = [
(str(k), v)
for k, v in Platform.objects.order_by("name").values_list("id", "name")
]
type_opts = [(t[0], t[1]) for t in Purchase.TYPES]
own_opts = [(t[0], t[1]) for t in Purchase.OWNERSHIP_TYPES]
existing = _filter_parse(filter_json)
gs, ge, gm = _filter_get_choice(existing, "games")
ps, pe, pm = _filter_get_choice(existing, "platform")
ts, te, tm = _filter_get_choice(existing, "type")
os_, oe, om = _filter_get_choice(existing, "ownership_type")
price = existing.get("price", {})
pmin = str(price.get("value", "")) if isinstance(price, dict) else ""
pmax = str(price.get("value2", "")) if isinstance(price, dict) else ""
rf = (
existing.get("is_refunded", {}).get("value", False)
if isinstance(existing.get("is_refunded"), dict)
else False
)
try:
a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price"))
plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1)
except Exception:
plo, phi = 0, 100
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
SelectableFilter("games", game_opts, gs, ge, gm, nullable=False),
),
_filter_field(
"Platform",
SelectableFilter(
"platform",
plat_opts,
ps,
pe,
pm,
nullable=Purchase._meta.get_field("platform").null,
),
),
_filter_field(
"Type",
SelectableFilter(
"type",
type_opts,
ts,
te,
tm,
nullable=not Purchase._meta.get_field("type").has_default(),
),
),
_filter_field(
"Ownership",
SelectableFilter(
"ownership_type",
own_opts,
os_,
oe,
om,
nullable=not Purchase._meta.get_field(
"ownership_type"
).has_default(),
),
),
],
),
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_number("Price Min", "filter-price-min", pmin, "0.00"),
_filter_number("Price Max", "filter-price-max", pmax, "100.00"),
_filter_checkbox("filter-refunded", "Refunded", rf),
],
),
_filter_range_handles(
"price-range", "filter-price-min", "filter-price-max", plo, phi
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
+784
View File
@@ -0,0 +1,784 @@
"""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 = Component(
tag_name="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 -->"
),
Component(
tag_name="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 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 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=[
Component(
tag_name="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 = Component(
tag_name="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,
)
],
)
+477
View File
@@ -0,0 +1,477 @@
"""
Typed criterion inputs for building structured filters.
Inspired by Stash's filter architecture: every filterable field uses a typed
criterion with a value and a CriterionModifier. This separates *what* you're
filtering from *how* you're comparing, and makes filter serialization trivial.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, fields as dc_fields
from enum import Enum
from typing import Any, Self, TypeVar
from django.db.models import Q
# ── Modifier ──────────────────────────────────────────────────────────────
class Modifier(str, Enum):
"""Comparison operators shared across all criterion types."""
EQUALS = "EQUALS"
NOT_EQUALS = "NOT_EQUALS"
GREATER_THAN = "GREATER_THAN"
LESS_THAN = "LESS_THAN"
BETWEEN = "BETWEEN"
NOT_BETWEEN = "NOT_BETWEEN"
INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL"
IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX"
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
@classmethod
def for_strings(cls) -> list[Self]:
return [
cls.EQUALS,
cls.NOT_EQUALS,
cls.INCLUDES,
cls.EXCLUDES,
cls.MATCHES_REGEX,
cls.NOT_MATCHES_REGEX,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
def for_numbers(cls) -> list[Self]:
return [
cls.EQUALS,
cls.NOT_EQUALS,
cls.GREATER_THAN,
cls.LESS_THAN,
cls.BETWEEN,
cls.NOT_BETWEEN,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
def for_dates(cls) -> list[Self]:
return cls.for_numbers()
@classmethod
def for_multi(cls) -> list[Self]:
return [
cls.INCLUDES,
cls.EXCLUDES,
cls.INCLUDES_ALL,
cls.IS_NULL,
cls.NOT_NULL,
]
# ── Base criterion ─────────────────────────────────────────────────────────
T = TypeVar("T")
@dataclass
class _Criterion:
"""Base for all typed criteria."""
value: Any = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
raise NotImplementedError
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name in data:
val = data[f.name]
# Coerce string modifier to Modifier enum
if f.name == "modifier" and isinstance(val, str):
val = Modifier(val)
kwargs[f.name] = val
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is not None and v != f.default:
result[f.name] = v
return result
# ── Concrete criteria ──────────────────────────────────────────────────────
@dataclass
class StringCriterion(_Criterion):
value: str = ""
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.INCLUDES:
return Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.MATCHES_REGEX:
return Q(**{f"{field_name}__regex": self.value})
if m == Modifier.NOT_MATCHES_REGEX:
return ~Q(**{f"{field_name}__regex": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for string field")
@dataclass
class IntCriterion(_Criterion):
value: int = 0
value2: int | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for int field")
@dataclass
class FloatCriterion(_Criterion):
value: float = 0.0
value2: float | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for float field")
@dataclass
class DateCriterion(_Criterion):
value: str = ""
value2: str | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(
**{f"{field_name}__gt": self.value2}
)
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for date field")
@dataclass
class BoolCriterion(_Criterion):
value: bool = False
# Bool only makes sense with EQUALS
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
if self.modifier == Modifier.EQUALS:
return Q(**{field_name: self.value})
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
@dataclass
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL:
q = Q()
for v in self.value:
q &= Q(**{field_name: v})
return q
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ────────────────────────────────────────────────────
F = TypeVar("F", bound="OperatorFilter")
@dataclass
class OperatorFilter:
"""Mixin providing AND/OR/NOT composition for entity filter types.
Subclasses should declare nullable references to themselves::
@dataclass
class GameFilter(OperatorFilter):
AND: "GameFilter | None" = None
OR: "GameFilter | None" = None
NOT: "GameFilter | None" = None
name: StringCriterion | None = None
...
"""
def sub_filter(self) -> OperatorFilter | None:
"""Return the first non-None of AND / OR / NOT."""
for attr in ("AND", "OR", "NOT"):
if hasattr(self, attr):
v = getattr(self, attr)
if v is not None:
return v
return None
def _criterion_fields(self) -> list[str]:
"""Return field names that hold a _Criterion instance."""
names: list[str] = []
for f in dc_fields(self):
if f.name in ("AND", "OR", "NOT"):
continue
v = getattr(self, f.name)
if isinstance(v, _Criterion):
names.append(f.name)
return names
def to_q(self) -> Q:
"""Build a Django Q object from this filter and its sub-filters."""
q = Q()
for field_name in self._criterion_fields():
c = getattr(self, field_name)
if c is not None:
q &= c.to_q(field_name)
sub = self.sub_filter()
if sub is not None:
if getattr(self, "AND", None) is not None:
q &= sub.to_q()
elif getattr(self, "OR", None) is not None:
q |= sub.to_q()
elif getattr(self, "NOT", None) is not None:
q &= ~sub.to_q()
return q
@classmethod
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
# Resolve criterion class names to actual types
criterion_types: dict[str, type[_Criterion]] = {
"StringCriterion": StringCriterion,
"IntCriterion": IntCriterion,
"FloatCriterion": FloatCriterion,
"DateCriterion": DateCriterion,
"BoolCriterion": BoolCriterion,
"MultiCriterion": MultiCriterion,
"ChoiceCriterion": ChoiceCriterion,
}
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name not in data:
continue
raw = data[f.name]
if raw is None:
kwargs[f.name] = None
continue
# Recurse into sub-filters (AND / OR / NOT)
if f.name in ("AND", "OR", "NOT"):
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
continue
# Resolve criterion fields from string type annotation
f_type = f.type
if isinstance(f_type, str):
# e.g. "StringCriterion | None" → "StringCriterion"
f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type]
kwargs[f.name] = (
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
)
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = (
f_type.from_json(raw) if isinstance(raw, dict) else None
)
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is None:
continue
if f.name in ("AND", "OR", "NOT"):
result[f.name] = v.to_json()
elif isinstance(v, _Criterion):
j = v.to_json()
if j:
result[f.name] = j
return result
# ── JSON helpers ───────────────────────────────────────────────────────────
def filter_from_json(cls: type[F], json_str: str) -> F | None:
"""Deserialize a filter from a JSON string.
Usage:
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
games = Game.objects.filter(f.to_q())
"""
if not json_str:
return None
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return None
return cls.from_json(data)
def filter_to_json(f: OperatorFilter) -> str:
"""Serialize a filter to a JSON string for URL params or storage."""
return json.dumps(f.to_json())
+25
View File
@@ -0,0 +1,25 @@
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", ""))
+2 -2
View File
@@ -20,8 +20,8 @@ def import_data(data: DataList):
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
except:
pass
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
game_id = None
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
+54 -61
View File
@@ -4,7 +4,8 @@
@plugin '@tailwindcss/forms';
@plugin 'flowbite/plugin';
@source '../node_modules/flowbite/**/*.js';
@source "../node_modules/flowbite";
@import "flowbite/src/themes/default";
@custom-variant dark (&:is(.dark *));
@@ -25,6 +26,7 @@
--color-background: #1f2937;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -103,16 +105,6 @@
font-style: normal;
}
/* a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
/* form label {
@apply dark:text-slate-400;
} */
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
}
@@ -135,38 +127,17 @@
}
}
/* form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
/* @media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
} */
/* @media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} */
#button-container button {
@apply mx-1;
}
@@ -207,34 +178,56 @@ textarea:disabled {
padding-left: 1em;
}
/* .truncate-container {
@apply inline-block relative;
a {
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
}
} */
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
label {
@apply dark:text-slate-500;
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
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;
}
select {
@apply w-full px-3 py-2.5 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;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea {
@apply dark:bg-slate-600 dark:text-slate-300;
}
[type="submit"] {
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
}
form div label {
@apply dark:text-white;
}
form div {
@apply flex flex-col;
}
div [type="submit"] {
@apply mt-3;
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
}
}
+353
View File
@@ -0,0 +1,353 @@
"""A small fast_app-style layout system.
Instead of Django template inheritance (`{% extends "base.html" %}`), views
build their page body with Python components and wrap it with `Page()` /
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
"""
import json
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date
# Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>"""
# The main module script: crown icon mount + theme-toggle wiring.
# Split around the single dynamic value (game.mastered).
_MAIN_SCRIPT_A = """<script type="module">
document.addEventListener('DOMContentLoaded', () => {
if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
mastered: """
_MAIN_SCRIPT_B = """
});
}
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
themeToggleBtn.addEventListener('click', function () {
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
}
});
</script>"""
# Toast notification region (Alpine.js). Verbatim from the old base.html.
_TOAST_CONTAINER = """<div x-data="toastStore()"
role="region"
aria-label="Notifications"
aria-atomic="true"
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
<div x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
tabindex="0"
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
:class="{
'success': toast.type === 'success',
'error': toast.type === 'error',
'info': toast.type === 'info',
'warning': toast.type === 'warning',
'debug': toast.type === 'debug'
}"
@click="dismissToast(toast.id)"
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
@keydown.escape="dismissToast(toast.id)">
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
:class="{
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
}">
<span class="flex-shrink-0 mt-0.5"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-blue-500': toast.type === 'info',
'text-amber-500': toast.type === 'warning',
'text-gray-500': toast.type === 'debug'
}">
<template x-if="toast.type === 'success'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
</svg>
</template>
<template x-if="toast.type === 'debug'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</template>
</span>
<p class="flex-1 text-sm"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-blue-800 dark:text-blue-200': toast.type === 'info',
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
}"
x-text="toast.message"></p>
<button @click.stop="dismissToast(toast.id)"
class="flex-shrink-0"
:class="{
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>"""
def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
"""Top navigation bar."""
logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
</a>
<button data-collapse-toggle="navbar-dropdown" type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="flex items-center">
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</li>
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
</li>
<li>
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
</li>
<li>
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
New
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
<li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
<li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
<li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
<li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
</ul>
</div>
</li>
<li>
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
Manage
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
<li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
<li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
<li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
<li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
<li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
</ul>
</div>
</li>
<li>
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
</li>
</ul>
</div>
</div>
</nav>""")
def Page(
content: SafeText | str,
*,
request: HttpRequest,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
from games.views.general import global_current_year, model_counts
counts = model_counts(request)
year = global_current_year(request)["global_current_year"]
navbar = Navbar(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
)
messages = [
{"message": str(m.message), "type": (m.tags or "info")}
for m in get_messages(request)
]
# Embed as JSON; guard against `</script>` breaking out of the tag.
messages_json = json.dumps(messages).replace("</", "<\\/")
head = (
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
' <meta charset="utf-8" />\n'
' <meta name="description" content="Self-hosted time-tracker." />\n'
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
f' <script src="{static("js/htmx.min.js")}"></script>\n'
" <script>\n"
" htmx.config.scrollBehavior = 'smooth';\n"
" htmx.config.selfRequestsOnly = false;\n"
" </script>\n"
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n"
)
body = (
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
' <div class="flex flex-col min-h-screen">\n'
f" {navbar}\n"
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n"
f" {scripts}\n"
f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n'
" </body>\n</html>\n"
)
return mark_safe(head + body)
def render_page(
request: HttpRequest,
content: SafeText | str,
*,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
status: int = 200,
) -> HttpResponse:
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
return HttpResponse(
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
status=status,
)
+26 -3
View File
@@ -5,11 +5,34 @@ from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.core.paginator import Page, Paginator
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def paginate(request: HttpRequest, queryset, per_page: int = 10):
"""Standard list-view pagination.
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
to hand to ``paginated_table_content``.
"""
page_number = request.GET.get("page", 1)
limit = int(request.GET.get("limit", per_page))
object_list = queryset
page_obj: Page | None = None
if limit != 0:
page_obj = Paginator(queryset, limit).get_page(page_number)
object_list = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
return object_list, page_obj, elided_page_range
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
@@ -153,9 +176,9 @@ def redirect_to(default_view: str, *default_args):
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
# Execute the original view logic for its side effects, then
# redirect to `next_url` instead of returning its response.
view_func(request, *args, **kwargs)
return redirect(next_url)
return wrapped_view
+12 -21
View File
@@ -1,30 +1,21 @@
---
services:
backend:
image: registry.kucharczyk.xyz/timetracker
timetracker:
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
- TZ=${TZ:-Europe/Prague}
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
- PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes:
- "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
- "./data:/home/timetracker/app/data"
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports:
- "8000:8000"
depends_on:
- backend
volumes:
static-files:
+18 -18
View File
@@ -1,23 +1,23 @@
#!/bin/bash
# Apply database migrations
set -euo pipefail
echo "Apply database migrations"
python manage.py migrate
echo "Collect static files"
PUID=${PUID:-1000}
PGID=${PGID:-100}
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
usermod -d "/root" timetracker
groupmod -o -g "$PGID" timetracker
usermod -o -u "$PUID" timetracker
usermod -d "${USERHOME}" timetracker
mkdir -p /home/timetracker/app/data /var/log/supervisor
chmod 755 /home/timetracker/app
chmod 755 /home/timetracker/app/.venv
chown "$PUID:$PGID" /home/timetracker/app/data
chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate
python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
kill -SIGTERM "$django_q_pid"
}
trap _term SIGTERM
echo "Starting Django-Q cluster"
python manage.py qcluster & django_q_pid=$!
echo "Starting app"
python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid" "$django_q_pid"
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
+26 -4
View File
@@ -1,11 +1,12 @@
from datetime import date, datetime
from typing import List
from django.contrib import messages
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
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Game, PlayEvent
from games.models import Game, PlayEvent, Session
api = NinjaAPI()
playevent_router = Router()
@@ -54,7 +55,8 @@ def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
return 204, None
messages.success(request, "Status updated")
return Status(204, None)
@playevent_router.get("/", response=List[PlayEventOut])
@@ -65,6 +67,7 @@ def list_playevents(request):
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
messages.success(request, "Game played!")
return playevent
@@ -87,9 +90,28 @@ def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEven
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return 204, None
return Status(204, None)
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
session_router = Router()
class SessionDeviceUpdate(Schema):
device_id: int
@session_router.patch("/{session_id}/device", response={204: None})
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()
messages.success(request, "Device updated")
return Status(204, None)
api.add_router("/session", session_router)
+401
View File
@@ -0,0 +1,401 @@
"""
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("games")
if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
q &= self.date_refunded.to_q("date_refunded")
if self.is_refunded is not None:
q &= Q(date_refunded__isnull=not self.is_refunded.value)
if self.price is not None:
q &= self.price.to_q("price")
if self.converted_price is not None:
q &= self.converted_price.to_q("converted_price")
if self.price_currency is not None:
q &= self.price_currency.to_q("price_currency")
if self.num_purchases is not None:
q &= self.num_purchases.to_q("num_purchases")
if self.ownership_type is not None:
q &= self.ownership_type.to_q("ownership_type")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(games__name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── Convenience helpers ────────────────────────────────────────────────────
def parse_game_filter(json_str: str) -> GameFilter | None:
return filter_from_json(GameFilter, json_str)
def parse_session_filter(json_str: str) -> SessionFilter | None:
return filter_from_json(SessionFilter, json_str)
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
+8 -1
View File
@@ -2,27 +2,34 @@
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
group: Nintendo
created_at: 2024-01-01T00:00:00Z
+5 -2
View File
@@ -43,7 +43,9 @@ class SessionForm(forms.ModelForm):
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False
)
mark_as_played = forms.BooleanField(
required=False,
@@ -95,7 +97,7 @@ class PurchaseForm(forms.ModelForm):
# Automatically update related_purchase <select/>
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
self.fields["games"].widget.attrs.update(
{
"hx-trigger": "load, click",
@@ -104,6 +106,7 @@ class PurchaseForm(forms.ModelForm):
"hx-swap": "outerHTML",
}
)
self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
-1
View File
@@ -1 +0,0 @@
from .game import Mutation as GameMutation
-29
View File
@@ -1,29 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class UpdateGameMutation(graphene.Mutation):
class Arguments:
id = graphene.ID(required=True)
name = graphene.String()
year_released = graphene.Int()
wikidata = graphene.String()
game = graphene.Field(Game)
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
game_instance = GameModel.objects.get(pk=id)
if name is not None:
game_instance.name = name
if year_released is not None:
game_instance.year_released = year_released
if wikidata is not None:
game_instance.wikidata = wikidata
game_instance.save()
return UpdateGameMutation(game=game_instance)
class Mutation(graphene.ObjectType):
update_game = UpdateGameMutation.Field()
-5
View File
@@ -1,5 +0,0 @@
from .device import Query as DeviceQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery
from .session import Query as SessionQuery
-11
View File
@@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Device
from games.models import Device as DeviceModel
class Query(graphene.ObjectType):
devices = graphene.List(Device)
def resolve_devices(self, info, **kwargs):
return DeviceModel.objects.all()
-18
View File
@@ -1,18 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class Query(graphene.ObjectType):
games = graphene.List(Game)
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
def resolve_games(self, info, **kwargs):
return GameModel.objects.all()
def resolve_game_by_name(self, info, name):
try:
return GameModel.objects.get(name=name)
except GameModel.DoesNotExist:
return None
-11
View File
@@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Platform
from games.models import Platform as PlatformModel
class Query(graphene.ObjectType):
platforms = graphene.List(Platform)
def resolve_platforms(self, info, **kwargs):
return PlatformModel.objects.all()
-11
View File
@@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Purchase
from games.models import Purchase as PurchaseModel
class Query(graphene.ObjectType):
purchases = graphene.List(Purchase)
def resolve_purchases(self, info, **kwargs):
return PurchaseModel.objects.all()
-11
View File
@@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Session
from games.models import Session as SessionModel
class Query(graphene.ObjectType):
sessions = graphene.List(Session)
def resolve_sessions(self, info, **kwargs):
return SessionModel.objects.all()
-44
View File
@@ -1,44 +0,0 @@
from graphene_django import DjangoObjectType
from games.models import Device as DeviceModel
from games.models import Edition as EditionModel
from games.models import Game as GameModel
from games.models import Platform as PlatformModel
from games.models import Purchase as PurchaseModel
from games.models import Session as SessionModel
class Game(DjangoObjectType):
class Meta:
model = GameModel
fields = "__all__"
class Edition(DjangoObjectType):
class Meta:
model = EditionModel
fields = "__all__"
class Purchase(DjangoObjectType):
class Meta:
model = PurchaseModel
fields = "__all__"
class Session(DjangoObjectType):
class Meta:
model = SessionModel
fields = "__all__"
class Platform(DjangoObjectType):
class Meta:
model = PlatformModel
fields = "__all__"
class Device(DjangoObjectType):
class Meta:
model = DeviceModel
fields = "__all__"
+66
View File
@@ -0,0 +1,66 @@
import json
from django.conf import settings
from django.contrib import messages as django_messages
from django.contrib.messages import constants as message_constants
MESSAGE_LEVEL_MAP = {
message_constants.DEBUG: "debug",
message_constants.INFO: "info",
message_constants.SUCCESS: "success",
message_constants.WARNING: "warning",
message_constants.ERROR: "error",
}
class HTMXMessagesMiddleware:
"""
Converts Django messages into HX-Trigger headers so toasts display
automatically without changes to views.
Works for HTMX requests (processed natively by HTMX client),
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
for full-page loads (browsers ignore HX-Trigger).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
# so the message persists in the session for the redirect target page
if "HX-Redirect" in response:
return response
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:
backend._set_level(min_level)
messages = list(backend)
if not messages:
return response
triggers = []
for msg in messages:
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
triggers.append(
{
"message": msg.message,
"type": toast_type,
}
)
if triggers:
# Use last message (most recent) as the primary toast
trigger = triggers[-1]
response["HX-Trigger"] = json.dumps(
{
"show-toast": trigger,
}
)
return response
+227 -61
View File
@@ -6,99 +6,265 @@ 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,15 +4,14 @@ 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),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ 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,55 +5,66 @@ 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=""),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ 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,18 +4,17 @@ 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,14 +4,13 @@ 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,15 +6,24 @@ 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,15 +5,20 @@ 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,35 +5,39 @@ 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"),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-05-12 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_alter_purchase_date_purchased_and_more"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="needs_price_update",
field=models.BooleanField(db_index=True, default=True),
),
migrations.RunSQL(
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
),
]
@@ -0,0 +1,48 @@
# 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"],
},
),
]
+52 -23
View File
@@ -4,7 +4,7 @@ from datetime import timedelta
import requests
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Sum
from django.db.models import F, Q, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
@@ -66,7 +66,10 @@ class Game(models.Model):
return self.name
def finished(self):
return self.status == self.Status.FINISHED
return (
self.status == self.Status.FINISHED
or self.playevents.filter(ended__isnull=False).exists()
)
def abandoned(self):
return self.status == self.Status.ABANDONED
@@ -120,6 +123,19 @@ class PurchaseQueryset(models.QuerySet):
def games_only(self):
return self.filter(type=Purchase.GAME)
def finished(self):
return self.filter(
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
).distinct()
def abandoned(self):
return self.filter(games__status="a").distinct()
def dropped(self):
return self.filter(
Q(games__status="a") | Q(date_refunded__isnull=False)
).distinct()
class Purchase(models.Model):
PHYSICAL = "ph"
@@ -165,6 +181,7 @@ class Purchase(models.Model):
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="")
needs_price_update = models.BooleanField(default=True, db_index=True)
price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
@@ -226,30 +243,15 @@ class Purchase(models.Model):
def is_game(self):
return self.type == self.GAME
def price_or_currency_differ_from(self, purchase_to_compare):
return (
self.price != purchase_to_compare.price
or self.price_currency != purchase_to_compare.price_currency
)
def refund(self):
self.date_refunded = timezone.now()
self.save()
def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
if self.pk is not None:
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if existing_purchase.price_or_currency_differ_from(self):
from games.tasks import currency_to
exchange_rate = get_or_create_rate(
self.price_currency, currency_to, self.date_purchased.year
)
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
super().save(*args, **kwargs)
@@ -327,9 +329,6 @@ class Session(models.Model):
def finish_now(self):
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
@@ -481,3 +480,33 @@ class GameStatusChange(models.Model):
class Meta:
ordering = ["-timestamp"]
class FilterPreset(models.Model):
"""Saved filter configuration, following Stash's SavedFilter pattern.
Separates find_filter (sort/pagination), object_filter (criteria JSON),
and ui_options (presentation state) so they can evolve independently.
"""
class Meta:
ordering = ["name"]
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
]
name = models.CharField(max_length=255)
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
find_filter = models.JSONField(default=dict, blank=True)
object_filter = models.JSONField(default=dict, blank=True)
ui_options = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.get_mode_display()})"
-30
View File
@@ -1,30 +0,0 @@
import graphene
from games.graphql.mutations import GameMutation
from games.graphql.queries import (
DeviceQuery,
EditionQuery,
GameQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
)
class Query(
GameQuery,
EditionQuery,
DeviceQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)
+23
View File
@@ -17,6 +17,29 @@ from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(pre_save, sender=Purchase)
def store_purchase_price_snapshot(sender, instance, **kwargs):
"""Store old price values before save so we can detect changes."""
if instance.pk is not None:
try:
old_instance = sender.objects.get(pk=instance.pk)
instance._old_price = old_instance.price
instance._old_currency = old_instance.price_currency
except sender.DoesNotExist:
pass
@receiver(post_save, sender=Purchase)
def mark_needs_price_update(sender, instance, created, **kwargs):
"""Mark purchase for price update if price or currency changed."""
if not created and hasattr(instance, "_old_price"):
if (
instance.price != instance._old_price
or instance.price_currency != instance._old_currency
):
sender.objects.filter(pk=instance.pk).update(needs_price_update=True)
@receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, action, reverse, **kwargs):
if not reverse and action.startswith("post_"):
+2429 -396
View File
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
/**
* Filter bar — vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
// Read all SelectableFilter widgets first
readSelectableFilters(form);
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
filter[field] = {
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
// Skip if both equal the data range extremes (no real filter)
var yrMinNum = parseInt(yearMin, 10);
var yrMaxNum = parseInt(yearMax, 10);
if (yrMinNum === yrMaxNum) {
// don't add filter
} else {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
}
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
injectSearchInputs();
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
+37
View File
@@ -0,0 +1,37 @@
(function() {
htmx.defineExtension("hx-redirect-toast", {
isInlineSwap: function(swapStyle) {
return swapStyle === "hx-redirect-toast";
},
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
var xhr = htmxConfig.xhr;
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
// Redirect immediately (toast will be shown on the new page)
if (hxRedirect) {
window.location.href = hxRedirect;
}
// Only dispatch HX-Trigger events for toasts when not redirecting
if (!hxRedirect && hxTrigger) {
var triggers = JSON.parse(hxTrigger);
var events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach(function(triggerObj) {
Object.entries(triggerObj).forEach(function(entry) {
var name = entry[0];
var detail = entry[1];
try { detail = JSON.parse(detail); } catch(e) {}
target.dispatchEvent(new CustomEvent(name, {
detail: detail,
bubbles: true,
cancelable: true
}));
});
});
}
// Return null to prevent any DOM swap
return null;
}
});
})();
+1 -1
View File
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
/**
* Dual-handle range slider — pure JS with draggable handles.
*/
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
var track = slider.querySelector(".range-track-fill");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
var dMin = parseInt(slider.getAttribute("data-min"), 10);
var dMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
function percentToValue(p) {
var raw = dMin + (p / 100) * (dMax - dMin);
return Math.round(raw / step) * step;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
function setTargetVal(el, v) { if (el) el.value = v; }
function update() {
var minV = getTargetVal(minTarget);
var maxV = getTargetVal(maxTarget);
minV = clamp(minV, dMin, dMax);
maxV = clamp(maxV, dMin, dMax);
if (minV > maxV) minV = maxV;
if (maxV < minV) maxV = minV;
setTargetVal(minTarget, minV);
setTargetVal(maxTarget, maxV);
var minP = valueToPercent(minV);
var maxP = valueToPercent(maxV);
minHandle.style.left = minP + "%";
maxHandle.style.left = maxP + "%";
if (track) {
track.style.left = minP + "%";
track.style.width = (maxP - minP) + "%";
}
}
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var v = percentToValue(clamp(pct, 0, 100));
if (isMin) {
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
} else {
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
}
update();
// Trigger input event on the target so any listeners fire
var tgt = isMin ? minTarget : maxTarget;
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// Sync from inputs to slider
function fromInputs() { update(); }
if (minTarget) minTarget.addEventListener("input", fromInputs);
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
update();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
// Expose for manual re-init (filter bar toggle)
window.initRangeSliders = initAll;
})();
+149
View File
@@ -0,0 +1,149 @@
/**
* SelectableFilter widget — Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+173
View File
@@ -0,0 +1,173 @@
document.addEventListener("alpine:init", () => {
let idCounter = 0;
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
Alpine.store("toasts", {
toasts: [],
addToast(message, type) {
console.log("[toast] addToast called:", { message, type });
if (!type) type = "info";
const validTypes = ["success", "error", "info", "warning", "debug"];
if (!validTypes.includes(type)) type = "info";
if (this.toasts.length >= 3) {
console.log("[toast] max 3 toasts reached, removing oldest");
this.toasts.shift();
}
const id = ++idCounter;
console.log("[toast] toast added, count:", this.toasts.length);
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
if (type !== "error") {
const toast = this.toasts[this.toasts.length - 1];
const autoDismissDelay = type === "debug" ? 3000 : 5000;
toast.timer = setTimeout(() => {
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
this.dismissToast(id);
}, autoDismissDelay);
}
},
dismissToast(id) {
console.log("[toast] dismissToast for id:", id);
const idx = this.toasts.findIndex((t) => t.id === id);
if (idx === -1) { console.log("[toast] toast not found"); return; }
const toast = this.toasts[idx];
if (toast.timer) clearTimeout(toast.timer);
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
console.log("[toast] after dismiss, count:", this.toasts.length);
}, 300);
},
clearToastTimer(id) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.timer) {
console.log("[toast] pause timer for toast id:", id);
clearTimeout(toast.timer);
toast.timer = null;
toast.pausedAt = Date.now();
}
},
resumeToastTimer(id, duration) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.pausedAt && toast.timer === null) {
console.log("[toast] resume timer for toast id:", id);
toast.timer = setTimeout(() => {
this.dismissToast(id);
}, duration);
toast.pausedAt = null;
}
},
});
Alpine.data("toastStore", () => ({
init() {
console.log("[toast] toastStore.init running");
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
window.addEventListener("show-toast", (e) => {
console.log("[toast] show-toast event received:", e.detail);
if (Array.isArray(e.detail)) {
e.detail.forEach((msg) => {
Alpine.store("toasts").addToast(msg.message, msg.type);
});
} else {
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
}
});
try {
const script = document.getElementById("django-messages");
if (script) {
const msgs = JSON.parse(
script.textContent || script.innerText || "[]"
);
console.log("[toast] django-messages script found:", msgs);
if (Array.isArray(msgs)) {
msgs.forEach((msg) => {
console.log("[toast] loading django-message:", msg);
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
});
}
}
} catch (e) {
console.error("[toast] localStorage restore failed:", e);
// ignore parse errors
}
},
addToast(message, type) {
console.log("[toast] toastStore.addToast delegating:", { message, type });
Alpine.store("toasts").addToast(message, type);
},
dismissToast(id) {
console.log("[toast] toastStore.dismissToast delegating:", id);
Alpine.store("toasts").dismissToast(id);
},
}));
});
function toast(message, type) {
console.log("[toast] toast() called:", { message, type });
const evt = new CustomEvent("show-toast", {
detail: { message, type },
bubbles: true,
});
document.dispatchEvent(evt);
console.log("[toast] CustomEvent dispatched, type:", evt.type);
}
window.toast = toast;
/**
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
* Use this for any fetch() call that expects HX-Trigger headers
* (e.g., to show toasts via the HTMX middleware).
*
* @todo Migrate these call sites to hx-post + hx-on::after-request
* for HTMX-native toast handling.
*/
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
console.log("[fetchWithHtmxTriggers] fetching:", url);
return fetch(url, options).then(async (response) => {
console.log("[fetchWithHtmxTriggers] response status:", response.status);
const htmxTrigger = response.headers.get("HX-Trigger");
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
if (htmxTrigger) {
let triggers;
try {
triggers = JSON.parse(htmxTrigger);
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
} catch {
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
return response;
}
// Handle both single object and array of events
const events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach((triggerObj) => {
Object.entries(triggerObj).forEach(([name, detail]) => {
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
let parsedDetail = detail;
try {
parsedDetail = JSON.parse(detail);
} catch {
// keep as string
}
document.dispatchEvent(new CustomEvent(name, {
detail: parsedDetail,
bubbles: true,
}));
});
});
}
return response;
});
};
+60 -48
View File
@@ -1,79 +1,91 @@
import logging
import requests
from django.db import models
from django.template.defaultfilters import floatformat
logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase
logger = logging.getLogger("games")
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
def save_converted_info(purchase, converted_price, converted_currency):
def _get_exchange_rate(currency_from, currency_to, year):
logger.debug(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
).first()
if not rate:
logger.debug(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try:
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
elif rate:
rate = rate.rate
return rate
def _save_converted_price(purchase, converted_price, needs_update):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})"
)
purchase.converted_price = converted_price
purchase.converted_currency = converted_currency
purchase.save()
purchase.converted_currency = currency_to
if needs_update:
purchase.needs_price_update = False
purchase.save(
update_fields=["converted_price", "converted_currency", "needs_price_update"]
)
def convert_prices():
purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency=""
)
models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True)
).distinct()
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
return
for purchase in purchases:
needs_update = purchase.needs_price_update
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
save_converted_info(purchase, purchase.price, currency_to)
_save_converted_price(purchase, purchase.price, needs_update)
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
).first()
logger.info(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
if not exchange_rate:
logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
if exchange_rate:
save_converted_info(
rate = _get_exchange_rate(currency_from, currency_to, year)
if rate:
_save_converted_price(
purchase,
floatformat(purchase.price * exchange_rate.rate, 0),
currency_to,
floatformat(purchase.price * rate, 0),
needs_update,
)
-2
View File
@@ -1,2 +0,0 @@
<c-layouts.add>
</c-layouts.add>
-7
View File
@@ -1,7 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</c-slot>
</c-layouts.add>
-12
View File
@@ -1,12 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
</c-slot>
</c-layouts.add>
-36
View File
@@ -1,36 +0,0 @@
<c-layouts.add>
<c-slot name="form_content">
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container" hx-boost="false">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button"
data-target="{{ field.name }}"
data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
</c-slot>
</c-layouts.add>
-6
View File
@@ -1,6 +0,0 @@
<c-vars color="blue" size="base" type="button" />
<button type="{{ type }}"
title="{{ title }}"
class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% 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 %} focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-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>
-8
View File
@@ -1,8 +0,0 @@
<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 />
{% endif %}
{% endfor %}
</div>
@@ -1,23 +0,0 @@
<c-vars color="gray" />
<a href="{{ href }}"
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>
-13
View File
@@ -1,13 +0,0 @@
{% 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>
-18
View File
@@ -1,18 +0,0 @@
{% 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>
-10
View File
@@ -1,10 +0,0 @@
<span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game_id %}">
{% if slot %}
{{ slot }}
{% else %}
{{ name }}
{% endif %}
</a>
</span>
-16
View File
@@ -1,16 +0,0 @@
<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 %}
">&nbsp;</span>
{{ slot }}
</span>
-8
View File
@@ -1,8 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
-6
View File
@@ -1,6 +0,0 @@
<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>
@@ -1,6 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<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>
-6
View File
@@ -1,6 +0,0 @@
<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 %}
@@ -1,5 +0,0 @@
<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>
@@ -1 +0,0 @@
<c-icon.nintendo />
@@ -1,5 +0,0 @@
<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>
@@ -1,6 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<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>
@@ -1,6 +0,0 @@
<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>
-1
View File
@@ -1 +0,0 @@
<c-icon.playstation />
-5
View File
@@ -1,5 +0,0 @@
<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>
-6
View File
@@ -1,6 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<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>
@@ -1,8 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<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>
-24
View File
@@ -1,24 +0,0 @@
<c-layouts.base>
{% load static %}
{% if form_content %}
{{ form_content }}
{% else %}
<div class="max-width-container">
<div class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<div><input type="submit" value="Submit" /></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>

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