Merge pull request #19 from KucharczykL/claude/custom-elements-experimental-unity
Try unifying 3 different element interfaces
This commit is contained in:
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
|
|||||||
|
|
||||||
# CSRF trusted origins
|
# CSRF trusted origins
|
||||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||||
|
|
||||||
|
# Create a default admin/admin superuser on startup (for initial setup only)
|
||||||
|
CREATE_DEFAULT_SUPERUSER=false
|
||||||
|
|||||||
+2
-1
@@ -14,6 +14,7 @@ dist/
|
|||||||
.direnv
|
.direnv
|
||||||
.hermes/
|
.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/
|
/games/static/js/dist/
|
||||||
/ts/generated/
|
/ts/generated/
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ FROM node:22-bookworm-slim AS assets
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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 . .
|
||||||
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
|
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
|
||||||
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
|
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from common.components.core import (
|
|||||||
randomid,
|
randomid,
|
||||||
render,
|
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 (
|
from common.components.date_range_picker import (
|
||||||
DateRangeCalendar,
|
DateRangeCalendar,
|
||||||
DateRangeField,
|
DateRangeField,
|
||||||
@@ -48,7 +48,6 @@ from common.components.primitives import (
|
|||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
@@ -68,6 +67,7 @@ from common.components.primitives import (
|
|||||||
SimpleTable,
|
SimpleTable,
|
||||||
Span,
|
Span,
|
||||||
StaticScript,
|
StaticScript,
|
||||||
|
StyledButton,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableTd,
|
TableTd,
|
||||||
@@ -77,6 +77,7 @@ from common.components.primitives import (
|
|||||||
Tr,
|
Tr,
|
||||||
Ul,
|
Ul,
|
||||||
YearPicker,
|
YearPicker,
|
||||||
|
custom_element_builder,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.search_select import (
|
from common.components.search_select import (
|
||||||
@@ -92,8 +93,9 @@ from common.utils import truncate
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"truncate",
|
"truncate",
|
||||||
"BaseComponent",
|
"BaseComponent",
|
||||||
"custom_element",
|
|
||||||
"register_element",
|
"register_element",
|
||||||
|
"SessionTimestampButtons",
|
||||||
|
"custom_element_builder",
|
||||||
"Element",
|
"Element",
|
||||||
"Fragment",
|
"Fragment",
|
||||||
"Media",
|
"Media",
|
||||||
@@ -107,7 +109,7 @@ __all__ = [
|
|||||||
"randomid",
|
"randomid",
|
||||||
"A",
|
"A",
|
||||||
"AddForm",
|
"AddForm",
|
||||||
"Button",
|
"StyledButton",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"CsrfInput",
|
"CsrfInput",
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ reader so drift fails ``tsc``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -33,22 +34,6 @@ def _kebab(name: str) -> str:
|
|||||||
return name.replace("_", "-")
|
return name.replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
def custom_element(
|
|
||||||
tag: str, props: Mapping[str, object], *, children: Children = None
|
|
||||||
) -> Node:
|
|
||||||
"""Emit ``<tag kebab-attrs>children</tag>`` and declare its compiled module.
|
|
||||||
|
|
||||||
The module path mirrors the source layout: ``ts/elements/<tag>.ts`` compiles
|
|
||||||
to ``dist/elements/<tag>.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 ──────────────────────────────────────────────────────────────────
|
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||||
@@ -121,3 +106,22 @@ class PlayEventRowProps(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
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")
|
||||||
|
|||||||
@@ -228,9 +228,8 @@ _SELECTOR_OPTION_CLASS = (
|
|||||||
|
|
||||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
"""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.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
|
from common.components.primitives import Li, Ul
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
@@ -266,18 +265,15 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
|||||||
dropdown = Div(
|
dropdown = Div(
|
||||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||||
)[toggle, menu]
|
)[toggle, menu]
|
||||||
return custom_element(
|
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
||||||
"game-status-selector",
|
Div(class_="flex gap-2 items-center")[dropdown]
|
||||||
GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
|
]
|
||||||
children=[Div(class_="flex gap-2 items-center")[dropdown]],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||||
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
"""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.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
|
from common.components.primitives import Li, Ul
|
||||||
|
|
||||||
current_name = session.device.name if session.device else "Unknown"
|
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(
|
dropdown = Div(
|
||||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||||
)[toggle, menu]
|
)[toggle, menu]
|
||||||
return custom_element(
|
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
||||||
"session-device-selector",
|
Div(class_="flex gap-2 items-center")[dropdown]
|
||||||
SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
|
]
|
||||||
children=[Div(class_="flex gap-2 items-center")[dropdown]],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
|
|||||||
|
|
||||||
from django.middleware.csrf import get_token
|
from django.middleware.csrf import get_token
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
@@ -68,8 +67,21 @@ def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _html_element(tag_name: str):
|
def custom_element_builder(tag_name: str):
|
||||||
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
|
"""Create a tag builder for a custom element with auto-attached Media.
|
||||||
|
|
||||||
|
The module path follows the convention ``ts/elements/<tag>.ts`` →
|
||||||
|
``dist/elements/<tag>.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(
|
def element(
|
||||||
attributes: Attributes | None = None,
|
attributes: Attributes | None = None,
|
||||||
@@ -77,13 +89,16 @@ def _html_element(tag_name: str):
|
|||||||
**attrs: object,
|
**attrs: object,
|
||||||
) -> Element:
|
) -> Element:
|
||||||
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
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.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
|
||||||
element.__doc__ = f"Builder for the <{tag_name}> element."
|
element.__doc__ = f"Builder for the <{tag_name}> element."
|
||||||
return element
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
A = _html_element("a")
|
||||||
|
Button = _html_element("button")
|
||||||
Div = _html_element("div")
|
Div = _html_element("div")
|
||||||
P = _html_element("p")
|
P = _html_element("p")
|
||||||
Ul = _html_element("ul")
|
Ul = _html_element("ul")
|
||||||
@@ -204,35 +219,7 @@ def PopoverTruncated(
|
|||||||
return input_string
|
return input_string
|
||||||
|
|
||||||
|
|
||||||
def A(
|
def StyledButton(
|
||||||
attributes: Attributes | None = None,
|
|
||||||
children: Children = None,
|
|
||||||
url_name: str | None = None,
|
|
||||||
href: str | None = None,
|
|
||||||
) -> Element:
|
|
||||||
"""
|
|
||||||
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 = 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(
|
|
||||||
attributes: Attributes | None = None,
|
attributes: Attributes | None = None,
|
||||||
children: Children = None,
|
children: Children = None,
|
||||||
size: str = "base",
|
size: str = "base",
|
||||||
@@ -245,8 +232,9 @@ def Button(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
onclick: str = "",
|
onclick: str = "",
|
||||||
name: str = "",
|
name: str = "",
|
||||||
|
**attrs: object,
|
||||||
) -> Element:
|
) -> Element:
|
||||||
attributes = as_attributes(attributes)
|
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||||
children = children or []
|
children = children or []
|
||||||
|
|
||||||
# Separate custom class from other generic attributes
|
# Separate custom class from other generic attributes
|
||||||
@@ -668,7 +656,7 @@ def AddForm(
|
|||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
field_markup,
|
field_markup,
|
||||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
|
||||||
Div(
|
Div(
|
||||||
[("class", "submit-button-container")],
|
[("class", "submit-button-container")],
|
||||||
[additional_row] if additional_row else [],
|
[additional_row] if additional_row else [],
|
||||||
|
|||||||
+7
-2
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from django.contrib.messages import get_messages
|
from django.contrib.messages import get_messages
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
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
|
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.
|
"""Top navigation bar.
|
||||||
|
|
||||||
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
|
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
|
|||||||
<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>
|
<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>
|
||||||
<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>
|
<form method="post" action="{reverse("logout")}">
|
||||||
|
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
||||||
|
<button type="submit" 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</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +313,7 @@ def Page(
|
|||||||
today_played=counts["today_played"],
|
today_played=counts["today_played"],
|
||||||
last_7_played=counts["last_7_played"],
|
last_7_played=counts["last_7_played"],
|
||||||
current_year=year,
|
current_year=year,
|
||||||
|
csrf_token=get_token(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-100}
|
- PGID=${PGID:-100}
|
||||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
|
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
|
||||||
ports:
|
ports:
|
||||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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")` |
|
||||||
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
|
|||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
python manage.py collectstatic --clear --no-input
|
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
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
|
||||||
paginated_table_content,
|
|
||||||
DeviceFilterBar,
|
DeviceFilterBar,
|
||||||
|
Fragment,
|
||||||
|
Icon,
|
||||||
|
StyledButton,
|
||||||
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
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)
|
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
"header_action": A(href=reverse("games:add_device"))[
|
||||||
|
StyledButton()["Add device"]
|
||||||
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
|
|||||||
+28
-38
@@ -11,16 +11,15 @@ from django.urls import reverse
|
|||||||
from django.utils.safestring import SafeText
|
from django.utils.safestring import SafeText
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
|
Fragment,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
GameStatusSelector,
|
GameStatusSelector,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -35,6 +34,7 @@ from common.components import (
|
|||||||
Safe,
|
Safe,
|
||||||
SearchField,
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
|
StyledButton,
|
||||||
Ul,
|
Ul,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
@@ -90,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": Div(
|
"header_action": Div(
|
||||||
children=[
|
class_="flex justify-between",
|
||||||
SearchField(search_string=search_string),
|
)[
|
||||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
SearchField(search_string=search_string),
|
||||||
],
|
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
||||||
attributes=[("class", "flex justify-between")],
|
],
|
||||||
),
|
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Sort Name",
|
"Sort Name",
|
||||||
@@ -172,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
AddForm(
|
AddForm(
|
||||||
form,
|
form,
|
||||||
request=request,
|
request=request,
|
||||||
additional_row=Button(
|
additional_row=StyledButton(
|
||||||
[],
|
[],
|
||||||
"Submit & Create Purchase",
|
"Submit & Create Purchase",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -248,14 +247,14 @@ def _delete_game_confirmation_modal(
|
|||||||
Div(
|
Div(
|
||||||
[("class", "items-center mt-5")],
|
[("class", "items-center mt-5")],
|
||||||
[
|
[
|
||||||
Button(
|
StyledButton(
|
||||||
[("class", "w-full")],
|
[("class", "w-full")],
|
||||||
"Delete",
|
"Delete",
|
||||||
color="red",
|
color="red",
|
||||||
size="lg",
|
size="lg",
|
||||||
type="submit",
|
type="submit",
|
||||||
),
|
),
|
||||||
Button(
|
StyledButton(
|
||||||
[("class", "mt-0 w-full")],
|
[("class", "mt-0 w-full")],
|
||||||
"Cancel",
|
"Cancel",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -353,26 +352,26 @@ _PLAYED_MENU = (
|
|||||||
|
|
||||||
def _played_row(game: Game, request: HttpRequest) -> Node:
|
def _played_row(game: Game, request: HttpRequest) -> Node:
|
||||||
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||||
from common.components import Element, custom_element
|
from common.components import Element
|
||||||
from common.components.custom_elements import PlayEventRowProps
|
from common.components.custom_elements import _PlayEventRow
|
||||||
|
from common.components.primitives import Button
|
||||||
|
|
||||||
|
played: int = 0
|
||||||
played = game.playevents.count()
|
played = game.playevents.count()
|
||||||
|
|
||||||
count_button = A(href=reverse("games:add_playevent"))[
|
count_button = A(href=reverse("games:add_playevent"))[
|
||||||
Element(
|
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
||||||
"button",
|
Span(data_count="")[str(played)], " times"
|
||||||
[("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")],
|
]
|
||||||
[Span(data_count="")[str(played)], " times"],
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
||||||
Ul()[
|
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]))[
|
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
|
||||||
"Add playthrough..."
|
"Add playthrough..."
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[
|
Li(class_="px-4 py-2 cursor-pointer")[
|
||||||
Element(
|
Element(
|
||||||
"button",
|
"button",
|
||||||
[("type", "button"), ("data-add-play", "")],
|
[("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")[
|
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
|
||||||
count_button, toggle_group
|
count_button, toggle_group
|
||||||
]
|
]
|
||||||
return custom_element(
|
return _PlayEventRow(
|
||||||
"play-event-row",
|
game_id=game.id,
|
||||||
PlayEventRowProps(
|
csrf=get_token(request),
|
||||||
game_id=game.id,
|
api_create_url=reverse("api-1.0.0:create_playevent"),
|
||||||
csrf=get_token(request),
|
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
|
||||||
api_create_url=reverse("api-1.0.0:create_playevent"),
|
|
||||||
),
|
|
||||||
children=[
|
|
||||||
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:
|
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(
|
header_action = Div(
|
||||||
children=[
|
children=[
|
||||||
A(
|
A(href=reverse("games:add_session"))[
|
||||||
url_name="games:add_session",
|
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
||||||
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
|
],
|
||||||
),
|
|
||||||
A(
|
A(
|
||||||
href=reverse(
|
href=reverse(
|
||||||
"games:list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
@@ -705,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
|||||||
children=Popover(
|
children=Popover(
|
||||||
popover_content=last_session.game.name,
|
popover_content=last_session.game.name,
|
||||||
children=[
|
children=[
|
||||||
Button(
|
StyledButton(
|
||||||
icon=True,
|
icon=True,
|
||||||
color="gray",
|
color="gray",
|
||||||
size="xs",
|
size="xs",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Fragment,
|
||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
|
||||||
PlatformFilterBar,
|
PlatformFilterBar,
|
||||||
|
StyledButton,
|
||||||
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
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)
|
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(
|
"header_action": A(href=reverse("games:add_platform"))[
|
||||||
[], Button([], "Add platform"), url_name="games:add_platform"
|
StyledButton()["Add platform"]
|
||||||
),
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Icon",
|
"Icon",
|
||||||
|
|||||||
@@ -9,17 +9,16 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Fragment,
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
paginated_table_content,
|
|
||||||
PlayEventFilterBar,
|
PlayEventFilterBar,
|
||||||
|
StyledButton,
|
||||||
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, format_duration, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
|
|||||||
for row in row_list
|
for row in row_list
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"header_action": A(
|
"header_action": A(href=reverse("games:add_playevent"))[
|
||||||
[], Button([], "Add play event"), url_name="games:add_playevent"
|
StyledButton()["Add play event"]
|
||||||
),
|
],
|
||||||
"columns": list(filtered_column_list),
|
"columns": list(filtered_column_list),
|
||||||
"rows": filtered_row_list,
|
"rows": filtered_row_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
|
Fragment,
|
||||||
GameLink,
|
GameLink,
|
||||||
Icon,
|
Icon,
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
@@ -30,6 +29,7 @@ from common.components import (
|
|||||||
Node,
|
Node,
|
||||||
PriceConverted,
|
PriceConverted,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
|
StyledButton,
|
||||||
TableRow,
|
TableRow,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(
|
"header_action": A(href=reverse("games:add_purchase"))[
|
||||||
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
StyledButton()["Add purchase"]
|
||||||
),
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
|
|||||||
Td(),
|
Td(),
|
||||||
Td(
|
Td(
|
||||||
children=[
|
children=[
|
||||||
Button(
|
StyledButton(
|
||||||
[],
|
[],
|
||||||
"Submit & Create Session",
|
"Submit & Create Session",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
|||||||
Div(
|
Div(
|
||||||
[("class", "items-center mt-5")],
|
[("class", "items-center mt-5")],
|
||||||
[
|
[
|
||||||
Button(
|
StyledButton(
|
||||||
[("class", "w-full")],
|
[("class", "w-full")],
|
||||||
"Refund",
|
"Refund",
|
||||||
color="blue",
|
color="blue",
|
||||||
size="lg",
|
size="lg",
|
||||||
type="submit",
|
type="submit",
|
||||||
),
|
),
|
||||||
Button(
|
StyledButton(
|
||||||
[("class", "mt-0 w-full")],
|
[("class", "mt-0 w-full")],
|
||||||
"Cancel",
|
"Cancel",
|
||||||
color="gray",
|
color="gray",
|
||||||
|
|||||||
+23
-38
@@ -11,12 +11,11 @@ from django.utils import timezone
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
Fragment,
|
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Div,
|
Div,
|
||||||
|
Fragment,
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
@@ -25,6 +24,8 @@ from common.components import (
|
|||||||
Safe,
|
Safe,
|
||||||
SearchField,
|
SearchField,
|
||||||
SessionDeviceSelector,
|
SessionDeviceSelector,
|
||||||
|
SessionTimestampButtons,
|
||||||
|
StyledButton,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Span, Td, Tr
|
from common.components.primitives import Span, Td, Tr
|
||||||
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
Div(
|
Div(
|
||||||
children=[
|
children=[
|
||||||
A(
|
A(
|
||||||
url_name="games:add_session",
|
href=reverse("games:add_session"),
|
||||||
children=Button(
|
)[
|
||||||
|
StyledButton(
|
||||||
icon=True,
|
icon=True,
|
||||||
size="xs",
|
size="xs",
|
||||||
children=[Icon("play"), "LOG"],
|
)[Icon("play"), "LOG"]
|
||||||
),
|
],
|
||||||
),
|
|
||||||
A(
|
A(
|
||||||
href=reverse(
|
href=reverse(
|
||||||
"games:list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
children=Popover(
|
children=Popover(
|
||||||
popover_content=last_session.game.name,
|
popover_content=last_session.game.name,
|
||||||
children=[
|
children=[
|
||||||
Button(
|
StyledButton(
|
||||||
icon=True,
|
icon=True,
|
||||||
color="gray",
|
color="gray",
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
|
|||||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||||
children.append(
|
children.append(
|
||||||
Span(
|
SessionTimestampButtons(
|
||||||
attributes=[
|
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||||
(
|
hx_boost="false",
|
||||||
"class",
|
)[
|
||||||
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
||||||
),
|
"Set to now"
|
||||||
("hx-boost", "false"),
|
|
||||||
],
|
],
|
||||||
children=[
|
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
|
||||||
Button(
|
"Toggle text"
|
||||||
[("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="copy", size="xs")[
|
||||||
|
f"Copy {this_side} value to {other_side}"
|
||||||
|
],
|
||||||
|
]
|
||||||
)
|
)
|
||||||
rows.append(Div(children=children))
|
rows.append(Div(children=children))
|
||||||
return Fragment(*rows, separator="\n")
|
return Fragment(*rows, separator="\n")
|
||||||
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Add New Session",
|
title="Add New Session",
|
||||||
scripts=mark_safe(
|
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Edit Session",
|
title="Edit Session",
|
||||||
scripts=mark_safe(
|
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.template.defaultfilters import date as date_filter
|
|||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
Div,
|
Div,
|
||||||
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
|
|||||||
else "text-body hover:text-heading underline decoration-dotted"
|
else "text-body hover:text-heading underline decoration-dotted"
|
||||||
)
|
)
|
||||||
alltime_btn = A(
|
alltime_btn = A(
|
||||||
url_name="games:stats_alltime",
|
href=reverse("games:stats_alltime"),
|
||||||
attributes=[("class", alltime_classes)],
|
class_=alltime_classes,
|
||||||
children=["All-time stats"],
|
)["All-time stats"]
|
||||||
)
|
|
||||||
picker = YearPicker(
|
picker = YearPicker(
|
||||||
year=year_int,
|
year=year_int,
|
||||||
available_years=tuple(year_range or []),
|
available_years=tuple(year_range or []),
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
|
|||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
|
StyledButton,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import P
|
from common.components.primitives import P
|
||||||
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
|||||||
P(
|
P(
|
||||||
children=["Are you sure you want to delete this status change?"],
|
children=["Are you sure you want to delete this status change?"],
|
||||||
),
|
),
|
||||||
Button(
|
StyledButton(
|
||||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||||
),
|
),
|
||||||
A(
|
A(
|
||||||
[("class", "")],
|
[("class", "")],
|
||||||
Button([("class", "w-full")], "Cancel", color="gray"),
|
StyledButton([("class", "w-full")], "Cancel", color="gray"),
|
||||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
'@parcel/watcher': false
|
|
||||||
@@ -6,7 +6,7 @@ from django.test import SimpleTestCase
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common import components
|
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
|
# Component builders return lazy ``Node`` objects; these tests assert on rendered
|
||||||
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
|
# 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"))
|
result = str(components.A([], "x", href="/literal/path"))
|
||||||
self.assertIn('href="/literal/path"', result)
|
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):
|
def test_a_no_url_or_href(self):
|
||||||
result = str(components.A([], "link"))
|
result = str(components.A([], "link"))
|
||||||
self.assertIn("<a>link</a>", result)
|
self.assertIn("<a>link</a>", result)
|
||||||
self.assertNotIn("href=", 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):
|
def test_button_returns_safe_text(self):
|
||||||
result = str(components.Button([], "click"))
|
result = str(components.StyledButton([], "click"))
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<button", result)
|
self.assertIn("<button", result)
|
||||||
|
|
||||||
def test_button_default_colors(self):
|
def test_button_default_colors(self):
|
||||||
result = str(components.Button([], "click"))
|
result = str(components.StyledButton([], "click"))
|
||||||
self.assertIn("text-white bg-brand", result)
|
self.assertIn("text-white bg-brand", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_link(self):
|
def test_name_with_icon_no_link(self):
|
||||||
@@ -269,7 +256,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
def test_component_output_starts_with_tag(self):
|
def test_component_output_starts_with_tag(self):
|
||||||
for label, html in [
|
for label, html in [
|
||||||
("A", str(components.A(href="/foo", children=["link"]))),
|
("A", str(components.A(href="/foo", children=["link"]))),
|
||||||
("Button", str(components.Button([], "click"))),
|
("Button", str(components.StyledButton([], "click"))),
|
||||||
("Div", str(components.Div([], ["hello"]))),
|
("Div", str(components.Div([], ["hello"]))),
|
||||||
("Input", str(components.Input())),
|
("Input", str(components.Input())),
|
||||||
("ButtonGroup", str(components.ButtonGroup([]))),
|
("ButtonGroup", str(components.ButtonGroup([]))),
|
||||||
@@ -294,7 +281,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_button_with_icon_children_not_escaped(self):
|
def test_button_with_icon_children_not_escaped(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Button(
|
components.StyledButton(
|
||||||
icon=True,
|
icon=True,
|
||||||
size="xs",
|
size="xs",
|
||||||
children=[components.Icon("play"), "LOG"],
|
children=[components.Icon("play"), "LOG"],
|
||||||
@@ -307,7 +294,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
components.Popover(
|
components.Popover(
|
||||||
popover_content="test tooltip",
|
popover_content="test tooltip",
|
||||||
children=[
|
children=[
|
||||||
components.Button(
|
components.StyledButton(
|
||||||
icon=True,
|
icon=True,
|
||||||
color="gray",
|
color="gray",
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -923,6 +910,7 @@ class ComponentPrimitivesTest(SimpleTestCase):
|
|||||||
class PrimitiveWidgetsTest(SimpleTestCase):
|
class PrimitiveWidgetsTest(SimpleTestCase):
|
||||||
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
||||||
|
|
||||||
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from common.components import custom_element, render
|
from common.components import custom_element_builder, render
|
||||||
from common.components.custom_elements import (
|
from common.components.custom_elements import (
|
||||||
ElementSpec,
|
ElementSpec,
|
||||||
_ts_for_spec,
|
_ts_for_spec,
|
||||||
@@ -17,9 +17,8 @@ class SampleProps(TypedDict):
|
|||||||
|
|
||||||
class CustomElementBuilderTest(unittest.TestCase):
|
class CustomElementBuilderTest(unittest.TestCase):
|
||||||
def test_serializes_props_to_kebab_attributes(self):
|
def test_serializes_props_to_kebab_attributes(self):
|
||||||
html = render(
|
x_sample = custom_element_builder("x-sample")
|
||||||
custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"])
|
html = render(x_sample(game_id=3, status="f")["hi"])
|
||||||
)
|
|
||||||
self.assertIn("<x-sample", html)
|
self.assertIn("<x-sample", html)
|
||||||
self.assertIn('game-id="3"', html)
|
self.assertIn('game-id="3"', html)
|
||||||
self.assertIn('status="f"', html)
|
self.assertIn('status="f"', html)
|
||||||
@@ -28,7 +27,8 @@ class CustomElementBuilderTest(unittest.TestCase):
|
|||||||
def test_declares_compiled_module_media(self):
|
def test_declares_compiled_module_media(self):
|
||||||
from common.components import collect_media
|
from common.components import collect_media
|
||||||
|
|
||||||
node = custom_element("x-sample", {"game_id": 3})
|
x_sample = custom_element_builder("x-sample")
|
||||||
|
node = x_sample(game_id=3)
|
||||||
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
|
|
||||||
def test_add_session_form_has_timestamp_helpers(self):
|
def test_add_session_form_has_timestamp_helpers(self):
|
||||||
html = self.get("games:add_session").content.decode()
|
html = self.get("games:add_session").content.decode()
|
||||||
self.assertIn("add_session.js", html)
|
self.assertIn("session-timestamp-buttons", html)
|
||||||
for marker in [
|
for marker in [
|
||||||
"Set to now",
|
"Set to now",
|
||||||
"Toggle text",
|
"Toggle text",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// import { toISOUTCString } from "../../games/static/js/utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toISOUTCString(date: Date): string {
|
||||||
|
function stringAndPad(number: number): string {
|
||||||
|
return number.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = stringAndPad(date.getMonth() + 1);
|
||||||
|
const day = stringAndPad(date.getDate());
|
||||||
|
const hours = stringAndPad(date.getHours());
|
||||||
|
const minutes = stringAndPad(date.getMinutes());
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionTimestampButtonsElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
for (const button of this.querySelectorAll("[data-target]")) {
|
||||||
|
const target = button.getAttribute("data-target");
|
||||||
|
const type = button.getAttribute("data-type");
|
||||||
|
if (!target || !type) continue;
|
||||||
|
const targetElement = document.querySelector(`#id_${target}`);
|
||||||
|
if (!(targetElement instanceof HTMLInputElement)) return;
|
||||||
|
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";
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user