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(" { + event.preventDefault(); + if (type == "now") { + targetElement.value = toISOUTCString(new Date()); + } else if (type == "copy") { + const oppositeName = + targetElement.name == "timestamp_start" + ? "timestamp_end" + : "timestamp_start"; + const opposite = document.querySelector(`[name='${oppositeName}']`); + if (!(opposite instanceof HTMLInputElement)) return; + opposite.value = targetElement.value; + } else if (type == "toggle") { + if (targetElement.type == "datetime-local") targetElement.type = "text"; + else targetElement.type = "datetime-local"; + } + }); + } + } +} + +customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);