diff --git a/.env.example b/.env.example
index ddac900..ccd3166 100644
--- a/.env.example
+++ b/.env.example
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
+
+# Create a default admin/admin superuser on startup (for initial setup only)
+CREATE_DEFAULT_SUPERUSER=false
diff --git a/.gitignore b/.gitignore
index 0f08154..00ec67a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ dist/
.direnv
.hermes/
-# TypeScript: compiled output and codegen are build-only
+# Build artifacts: generated in CI/Docker assets stage, not committed
+/games/static/base.css
/games/static/js/dist/
/ts/generated/
diff --git a/Dockerfile b/Dockerfile
index 7f2f325..abd2d5a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,7 @@ FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
-RUN npm install -g pnpm && pnpm install --frozen-lockfile
+RUN npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
COPY . .
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
diff --git a/common/components/__init__.py b/common/components/__init__.py
index 6e8795e..a97c062 100644
--- a/common/components/__init__.py
+++ b/common/components/__init__.py
@@ -18,7 +18,7 @@ from common.components.core import (
randomid,
render,
)
-from common.components.custom_elements import custom_element, register_element
+from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
@@ -48,7 +48,6 @@ from common.components.primitives import (
H1,
A,
AddForm,
- Button,
ButtonGroup,
Checkbox,
CsrfInput,
@@ -68,6 +67,7 @@ from common.components.primitives import (
SimpleTable,
Span,
StaticScript,
+ StyledButton,
TableHeader,
TableRow,
TableTd,
@@ -77,6 +77,7 @@ from common.components.primitives import (
Tr,
Ul,
YearPicker,
+ custom_element_builder,
paginated_table_content,
)
from common.components.search_select import (
@@ -92,8 +93,9 @@ from common.utils import truncate
__all__ = [
"truncate",
"BaseComponent",
- "custom_element",
"register_element",
+ "SessionTimestampButtons",
+ "custom_element_builder",
"Element",
"Fragment",
"Media",
@@ -107,7 +109,7 @@ __all__ = [
"randomid",
"A",
"AddForm",
- "Button",
+ "StyledButton",
"ButtonGroup",
"Checkbox",
"CsrfInput",
diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
index 0b42c7c..1ae485a 100644
--- a/common/components/custom_elements.py
+++ b/common/components/custom_elements.py
@@ -9,9 +9,10 @@ reader so drift fails ``tsc``.
"""
from dataclasses import dataclass
-from typing import Mapping, TypedDict, get_type_hints
+from typing import TypedDict, get_type_hints
-from common.components.core import Children, Element, HTMLAttribute, Media, Node
+from common.components.core import Media
+from common.components.primitives import custom_element_builder
@dataclass(frozen=True)
@@ -33,22 +34,6 @@ def _kebab(name: str) -> str:
return name.replace("_", "-")
-def custom_element(
- tag: str, props: Mapping[str, object], *, children: Children = None
-) -> Node:
- """Emit ``children`` and declare its compiled module.
-
- The module path mirrors the source layout: ``ts/elements/.ts`` compiles
- to ``dist/elements/.js``, which ``Media`` loads via ``ModuleScript``."""
- attributes: list[HTMLAttribute] = [
- (_kebab(key), value) # type: ignore[misc]
- for key, value in props.items()
- ]
- return Element(tag, attributes, children).with_media(
- Media(js=(f"dist/elements/{tag}.js",))
- )
-
-
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
@@ -121,3 +106,22 @@ class PlayEventRowProps(TypedDict):
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
+
+
+class SessionTimestampButtonsProps(TypedDict):
+ pass
+
+
+register_element(
+ "session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
+)
+
+
+# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
+# Underscore-prefixed: used internally by domain wrappers.
+# Public ones (no domain wrapper): exported directly.
+
+_GameStatusSelector = custom_element_builder("game-status-selector")
+_SessionDeviceSelector = custom_element_builder("session-device-selector")
+_PlayEventRow = custom_element_builder("play-event-row")
+SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
diff --git a/common/components/domain.py b/common/components/domain.py
index e4e0873..68fa94b 100644
--- a/common/components/domain.py
+++ b/common/components/domain.py
@@ -228,9 +228,8 @@ _SELECTOR_OPTION_CLASS = (
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
- from common.components import custom_element
from common.components.core import Element
- from common.components.custom_elements import GameStatusSelectorProps
+ from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
from common.components.primitives import Li, Ul
options = [
@@ -266,18 +265,15 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
- return custom_element(
- "game-status-selector",
- GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
- children=[Div(class_="flex gap-2 items-center")[dropdown]],
- )
+ return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
+ Div(class_="flex gap-2 items-center")[dropdown]
+ ]
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
- from common.components import custom_element
from common.components.core import Element
- from common.components.custom_elements import SessionDeviceSelectorProps
+ from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown"
@@ -307,8 +303,6 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu]
- return custom_element(
- "session-device-selector",
- SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
- children=[Div(class_="flex gap-2 items-center")[dropdown]],
- )
+ return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
+ Div(class_="flex gap-2 items-center")[dropdown]
+ ]
diff --git a/common/components/primitives.py b/common/components/primitives.py
index 24c551c..ef1ec21 100644
--- a/common/components/primitives.py
+++ b/common/components/primitives.py
@@ -9,7 +9,6 @@ Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
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
@@ -68,8 +67,21 @@ def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
return result
-def _html_element(tag_name: str):
- """Build a generic element builder for ``tag_name`` (the whitelist factory)."""
+def custom_element_builder(tag_name: str):
+ """Create a tag builder for a custom element with auto-attached Media.
+
+ The module path follows the convention ``ts/elements/.ts`` →
+ ``dist/elements/.js``.
+ """
+ return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
+
+
+def _html_element(tag_name: str, media: Media | None = None):
+ """Build a generic element builder for ``tag_name`` (the whitelist factory).
+
+ If ``media`` is provided, every node created by the builder will carry it
+ (used for custom elements whose compiled JS must be loaded automatically).
+ """
def element(
attributes: Attributes | None = None,
@@ -77,13 +89,16 @@ def _html_element(tag_name: str):
**attrs: object,
) -> Element:
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
- return Element(tag_name, merged, children)
+ node = Element(tag_name, merged, children)
+ return node.with_media(media) if media else node
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
+A = _html_element("a")
+Button = _html_element("button")
Div = _html_element("div")
P = _html_element("p")
Ul = _html_element("ul")
@@ -204,35 +219,7 @@ def PopoverTruncated(
return input_string
-def A(
- attributes: Attributes | None = None,
- children: Children = None,
- url_name: str | None = None,
- href: str | None = None,
-) -> Element:
- """
- Returns an anchor 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 = as_attributes(attributes)
- 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 Element(
- "a", attributes=attributes + additional_attributes, children=children
- )
-
-
-def Button(
+def StyledButton(
attributes: Attributes | None = None,
children: Children = None,
size: str = "base",
@@ -245,8 +232,9 @@ def Button(
title: str = "",
onclick: str = "",
name: str = "",
+ **attrs: object,
) -> Element:
- attributes = as_attributes(attributes)
+ attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
children = children or []
# Separate custom class from other generic attributes
@@ -668,7 +656,7 @@ def AddForm(
children=[
CsrfInput(request),
field_markup,
- Div(children=[Button(submit_attrs, "Submit", type="submit")]),
+ Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
Div(
[("class", "submit-button-container")],
[additional_row] if additional_row else [],
diff --git a/common/layout.py b/common/layout.py
index acc96ec..8328710 100644
--- a/common/layout.py
+++ b/common/layout.py
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
+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
@@ -186,7 +187,7 @@ 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) -> "Node":
+def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
"""Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
@@ -270,7 +271,10 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node
Stats
- Log out
+
@@ -309,6 +313,7 @@ def Page(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
+ csrf_token=get_token(request),
)
messages = [
diff --git a/docker-compose.yml b/docker-compose.yml
index 3ca2704..bf710ff 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,7 @@ services:
- PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
+ - CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes:
diff --git a/docs/custom-element-api.md b/docs/custom-element-api.md
new file mode 100644
index 0000000..6486a5c
--- /dev/null
+++ b/docs/custom-element-api.md
@@ -0,0 +1,51 @@
+# Custom Element API: Two patterns, one goal
+
+## Pattern 1: Named builder (current, preferred)
+
+A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
+
+```python
+# definition (custom_elements.py)
+SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
+
+# usage (session.py)
+SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
+ Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
+ Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
+]
+```
+
+**Pros:** explicit dependency, visible import, fails loudly if builder deleted
+**Cons:** one line of ceremony per element
+
+## Pattern 2: Element + registry (proposed, not implemented)
+
+A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
+
+```python
+# definition (custom_elements.py)
+register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
+# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
+
+# usage (session.py) — no builder import needed
+Element("session-timestamp-buttons",
+ [("class", "form-row-button-group"), ("hx-boost", "false")],
+ children=[...],
+)
+```
+
+**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
+**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
+
+## Recommendation
+
+Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
+
+## Quick reference
+
+| Want | Write |
+|------|-------|
+| Plain HTML tag | `Div(class_="flex")["text"]` |
+| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
+| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
+| Builder from scratch | `custom_element_builder("tag-name")` |
diff --git a/entrypoint.sh b/entrypoint.sh
index 2c3946d..4ef7de8 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate
python manage.py collectstatic --clear --no-input
+if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
+ python manage.py shell -c "
+from django.contrib.auth import get_user_model
+User = get_user_model()
+if not User.objects.filter(username='admin').exists():
+ User.objects.create_superuser('admin', '', 'admin')
+ print('Created default superuser: admin / admin')
+"
+fi
+
+chown -R "$PUID:$PGID" /home/timetracker/app/data
+
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
diff --git a/games/static/js/add_session.js b/games/static/js/add_session.js
deleted file mode 100644
index 8effc41..0000000
--- a/games/static/js/add_session.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { toISOUTCString } from "./utils.js";
-
-for (let button of document.querySelectorAll("[data-target]")) {
- let target = button.getAttribute("data-target");
- let type = button.getAttribute("data-type");
- let targetElement = document.querySelector(`#id_${target}`);
- button.addEventListener("click", (event) => {
- event.preventDefault();
- if (type == "now") {
- targetElement.value = toISOUTCString(new Date());
- } else if (type == "copy") {
- const oppositeName =
- targetElement.name == "timestamp_start"
- ? "timestamp_end"
- : "timestamp_start";
- document.querySelector(`[name='${oppositeName}']`).value =
- targetElement.value;
- } else if (type == "toggle") {
- if (targetElement.type == "datetime-local") targetElement.type = "text";
- else targetElement.type = "datetime-local";
- }
- });
-}
diff --git a/games/static/js/year_picker.js b/games/static/js/year_picker.js
new file mode 100644
index 0000000..7eeb843
--- /dev/null
+++ b/games/static/js/year_picker.js
@@ -0,0 +1,38 @@
+import { onSwap } from "./utils.js";
+
+onSwap("#year-picker-input", function(pickerEl) {
+ const selectedYear = pickerEl.dataset.selectedYear;
+ const urlTemplate = pickerEl.dataset.urlTemplate;
+ const currentYear = new Date().getFullYear();
+ const availableYears = new Set(
+ pickerEl.dataset.availableYears
+ .split(",")
+ .map(s => parseInt(s.trim()))
+ .filter(n => !isNaN(n))
+ );
+
+ const picker = new Datepicker(pickerEl, {
+ pickLevel: 2,
+ format: "yyyy",
+ minDate: new Date(1999, 0, 1),
+ maxDate: new Date(currentYear, 11, 31),
+ autohide: false,
+ orientation: "bottom end",
+ showOnClick: false,
+ showOnFocus: false,
+ beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
+ });
+ pickerEl._pickerInstance = picker;
+
+ picker.element.addEventListener("changeDate", (event) => {
+ const year = event.detail.date?.getFullYear();
+ if (year && urlTemplate) {
+ window.location.href = urlTemplate.replace("__year__", year);
+ }
+ });
+
+ if (selectedYear) {
+ picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
+ picker.update();
+ }
+});
diff --git a/games/views/device.py b/games/views/device.py
index 08d0a94..640203b 100644
--- a/games/views/device.py
+++ b/games/views/device.py
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import (
- Fragment,
A,
AddForm,
- Button,
ButtonGroup,
- Icon,
- paginated_table_content,
DeviceFilterBar,
+ Fragment,
+ Icon,
+ StyledButton,
+ paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
@@ -34,7 +34,9 @@ def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate(request, devices)
data = {
- "header_action": A([], Button([], "Add device"), url_name="games:add_device"),
+ "header_action": A(href=reverse("games:add_device"))[
+ StyledButton()["Add device"]
+ ],
"columns": [
"Name",
"Type",
diff --git a/games/views/game.py b/games/views/game.py
index 618f6a2..9a72075 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -11,16 +11,15 @@ from django.urls import reverse
from django.utils.safestring import SafeText
from common.components import (
- Fragment,
H1,
A,
AddForm,
- Button,
ButtonGroup,
CsrfInput,
Div,
Element,
FilterBar,
+ Fragment,
GameStatus,
GameStatusSelector,
Icon,
@@ -35,6 +34,7 @@ from common.components import (
Safe,
SearchField,
SimpleTable,
+ StyledButton,
Ul,
paginated_table_content,
)
@@ -90,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = {
"header_action": Div(
- children=[
- SearchField(search_string=search_string),
- A([], Button([], "Add game"), url_name="games:add_game"),
- ],
- attributes=[("class", "flex justify-between")],
- ),
+ class_="flex justify-between",
+ )[
+ SearchField(search_string=search_string),
+ A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
+ ],
"columns": [
"Name",
"Sort Name",
@@ -172,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm(
form,
request=request,
- additional_row=Button(
+ additional_row=StyledButton(
[],
"Submit & Create Purchase",
color="gray",
@@ -248,14 +247,14 @@ def _delete_game_confirmation_modal(
Div(
[("class", "items-center mt-5")],
[
- Button(
+ StyledButton(
[("class", "w-full")],
"Delete",
color="red",
size="lg",
type="submit",
),
- Button(
+ StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
@@ -353,26 +352,26 @@ _PLAYED_MENU = (
def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
- from common.components import Element, custom_element
- from common.components.custom_elements import PlayEventRowProps
+ from common.components import Element
+ from common.components.custom_elements import _PlayEventRow
+ from common.components.primitives import Button
+ played: int = 0
played = game.playevents.count()
count_button = A(href=reverse("games:add_playevent"))[
- Element(
- "button",
- [("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")],
- [Span(data_count="")[str(played)], " times"],
- )
+ Button(class_=_PLAYED_BTN + " rounded-s-lg")[
+ Span(data_count="")[str(played)], " times"
+ ]
]
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[
- Li(attributes=[("class", "px-4 py-2")])[
+ Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..."
]
],
- Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[
+ Li(class_="px-4 py-2 cursor-pointer")[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
@@ -397,19 +396,11 @@ def _played_row(game: Game, request: HttpRequest) -> Node:
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group
]
- return custom_element(
- "play-event-row",
- PlayEventRowProps(
- game_id=game.id,
- csrf=get_token(request),
- api_create_url=reverse("api-1.0.0:create_playevent"),
- ),
- children=[
- Div(class_="flex gap-2 items-center")[
- Span(class_="uppercase")["Played"], group
- ]
- ],
- )
+ return _PlayEventRow(
+ game_id=game.id,
+ csrf=get_token(request),
+ api_create_url=reverse("api-1.0.0:create_playevent"),
+ )[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -693,10 +684,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
header_action = Div(
children=[
- A(
- url_name="games:add_session",
- children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
- ),
+ A(href=reverse("games:add_session"))[
+ StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
+ ],
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -705,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover(
popover_content=last_session.game.name,
children=[
- Button(
+ StyledButton(
icon=True,
color="gray",
size="xs",
diff --git a/games/views/platform.py b/games/views/platform.py
index 2a86b33..4cc720a 100644
--- a/games/views/platform.py
+++ b/games/views/platform.py
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import (
- Fragment,
A,
AddForm,
- Button,
ButtonGroup,
+ Fragment,
Icon,
- paginated_table_content,
PlatformFilterBar,
+ StyledButton,
+ paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
@@ -35,9 +35,9 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate(request, platforms)
data = {
- "header_action": A(
- [], Button([], "Add platform"), url_name="games:add_platform"
- ),
+ "header_action": A(href=reverse("games:add_platform"))[
+ StyledButton()["Add platform"]
+ ],
"columns": [
"Name",
"Icon",
diff --git a/games/views/playevent.py b/games/views/playevent.py
index d1efe58..2dd792c 100644
--- a/games/views/playevent.py
+++ b/games/views/playevent.py
@@ -9,17 +9,16 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
-
from common.components import (
- Fragment,
A,
AddForm,
- Button,
ButtonGroup,
+ Fragment,
Icon,
ModuleScript,
- paginated_table_content,
PlayEventFilterBar,
+ StyledButton,
+ paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
for row in row_list
]
return {
- "header_action": A(
- [], Button([], "Add play event"), url_name="games:add_playevent"
- ),
+ "header_action": A(href=reverse("games:add_playevent"))[
+ StyledButton()["Add play event"]
+ ],
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
diff --git a/games/views/purchase.py b/games/views/purchase.py
index 2f4d0db..6b3c4a1 100644
--- a/games/views/purchase.py
+++ b/games/views/purchase.py
@@ -14,14 +14,13 @@ from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST
from common.components import (
- Fragment,
A,
AddForm,
- Button,
ButtonGroup,
CsrfInput,
Div,
Element,
+ Fragment,
GameLink,
Icon,
LinkedPurchase,
@@ -30,6 +29,7 @@ from common.components import (
Node,
PriceConverted,
PurchasePrice,
+ StyledButton,
TableRow,
paginated_table_content,
)
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases)
data = {
- "header_action": A(
- [], Button([], "Add purchase"), url_name="games:add_purchase"
- ),
+ "header_action": A(href=reverse("games:add_purchase"))[
+ StyledButton()["Add purchase"]
+ ],
"columns": [
"Name",
"Type",
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
Td(),
Td(
children=[
- Button(
+ StyledButton(
[],
"Submit & Create Session",
color="gray",
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
Div(
[("class", "items-center mt-5")],
[
- Button(
+ StyledButton(
[("class", "w-full")],
"Refund",
color="blue",
size="lg",
type="submit",
),
- Button(
+ StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
diff --git a/games/views/session.py b/games/views/session.py
index a00cd25..2b5321b 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -11,12 +11,11 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from common.components import (
- Fragment,
A,
AddForm,
- Button,
ButtonGroup,
Div,
+ Fragment,
Icon,
ModuleScript,
NameWithIcon,
@@ -25,6 +24,8 @@ from common.components import (
Safe,
SearchField,
SessionDeviceSelector,
+ SessionTimestampButtons,
+ StyledButton,
paginated_table_content,
)
from common.components.primitives import Span, Td, Tr
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div(
children=[
A(
- url_name="games:add_session",
- children=Button(
+ href=reverse("games:add_session"),
+ )[
+ StyledButton(
icon=True,
size="xs",
- children=[Icon("play"), "LOG"],
- ),
- ),
+ )[Icon("play"), "LOG"]
+ ],
A(
href=reverse(
"games:list_sessions_start_session_from_session",
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover(
popover_content=last_session.game.name,
children=[
- Button(
+ StyledButton(
icon=True,
color="gray",
size="xs",
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
- Span(
- attributes=[
- (
- "class",
- "form-row-button-group flex-row gap-3 justify-start mt-3",
- ),
- ("hx-boost", "false"),
+ SessionTimestampButtons(
+ class_="form-row-button-group flex-row gap-3 justify-start mt-3",
+ hx_boost="false",
+ )[
+ StyledButton(data_target=field.name, data_type="now", size="xs")[
+ "Set to now"
],
- children=[
- Button(
- [("data-target", field.name), ("data-type", "now")],
- "Set to now",
- size="xs",
- ),
- Button(
- [("data-target", field.name), ("data-type", "toggle")],
- "Toggle text",
- size="xs",
- ),
- Button(
- [("data-target", field.name), ("data-type", "copy")],
- f"Copy {this_side} value to {other_side}",
- size="xs",
- ),
+ StyledButton(data_target=field.name, data_type="toggle", size="xs")[
+ "Toggle text"
],
- )
+ StyledButton(data_target=field.name, data_type="copy", size="xs")[
+ f"Copy {this_side} value to {other_side}"
+ ],
+ ]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session",
- scripts=mark_safe(
- ModuleScript("search_select.js") + ModuleScript("add_session.js")
- ),
+ scripts=mark_safe(ModuleScript("search_select.js")),
)
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
- scripts=mark_safe(
- ModuleScript("search_select.js") + ModuleScript("add_session.js")
- ),
+ scripts=mark_safe(ModuleScript("search_select.js")),
)
diff --git a/games/views/stats_content.py b/games/views/stats_content.py
index b131b0b..dd84aec 100644
--- a/games/views/stats_content.py
+++ b/games/views/stats_content.py
@@ -9,6 +9,7 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.html import conditional_escape
+
from common.components import (
A,
Div,
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
else "text-body hover:text-heading underline decoration-dotted"
)
alltime_btn = A(
- url_name="games:stats_alltime",
- attributes=[("class", alltime_classes)],
- children=["All-time stats"],
- )
+ href=reverse("games:stats_alltime"),
+ class_=alltime_classes,
+ )["All-time stats"]
picker = YearPicker(
year=year_int,
available_years=tuple(year_range or []),
diff --git a/games/views/statuschange.py b/games/views/statuschange.py
index 651cc1f..317ee0d 100644
--- a/games/views/statuschange.py
+++ b/games/views/statuschange.py
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
from common.components import (
A,
AddForm,
- Button,
CsrfInput,
Div,
Element,
+ StyledButton,
paginated_table_content,
)
from common.components.primitives import P
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
P(
children=["Are you sure you want to delete this status change?"],
),
- Button(
+ StyledButton(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
),
A(
[("class", "")],
- Button([("class", "w-full")], "Cancel", color="gray"),
+ StyledButton([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]),
),
],
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
deleted file mode 100644
index 696c825..0000000
--- a/pnpm-workspace.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-allowBuilds:
- '@parcel/watcher': false
diff --git a/tests/test_components.py b/tests/test_components.py
index 64310b8..daf107d 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -6,7 +6,7 @@ from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe
from common import components
-from games.models import Platform, Game, Purchase, Session
+from games.models import Game, Platform, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
@@ -224,31 +224,18 @@ class ComponentReturnTypeTest(unittest.TestCase):
result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result)
- def test_a_url_name_reversed(self):
- from unittest.mock import patch
-
- with patch(
- "common.components.primitives.reverse", return_value="/resolved/url"
- ):
- result = str(components.A([], "link", url_name="some_name"))
- self.assertIn('href="/resolved/url"', result)
-
def test_a_no_url_or_href(self):
result = str(components.A([], "link"))
self.assertIn("link", result)
self.assertNotIn("href=", result)
- def test_a_both_url_name_and_href_raises(self):
- with self.assertRaises(ValueError):
- str(components.A(href="/path", url_name="some_name"))
-
def test_button_returns_safe_text(self):
- result = str(components.Button([], "click"))
+ result = str(components.StyledButton([], "click"))
self.assertIsInstance(result, SafeText)
self.assertIn("