Merge pull request #16 from KucharczykL/claude/custom-elements
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
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --frozen
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install pnpm and JS dependencies
|
||||||
|
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Build TypeScript
|
||||||
|
run: make ts
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: uv run playwright install --with-deps chromium
|
run: uv run playwright install --with-deps chromium
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -4,7 +4,6 @@ __pycache__
|
|||||||
.venv/
|
.venv/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
pnpm-lock.yaml
|
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
data/
|
data/
|
||||||
/static/
|
/static/
|
||||||
@@ -13,3 +12,8 @@ dist/
|
|||||||
.python-version
|
.python-version
|
||||||
.direnv
|
.direnv
|
||||||
.hermes/
|
.hermes/
|
||||||
|
|
||||||
|
# Build artifacts: generated in CI/Docker assets stage, not committed
|
||||||
|
/games/static/base.css
|
||||||
|
/games/static/js/dist/
|
||||||
|
/ts/generated/
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
|||||||
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
||||||
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
||||||
|
|
||||||
|
### Interactive components: custom elements + TypeScript
|
||||||
|
|
||||||
|
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
|
||||||
|
|
||||||
|
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/` → `games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
|
||||||
|
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_`→`class`, `hx_get`→`hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
|
||||||
|
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
|
||||||
|
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||||
|
|||||||
+21
@@ -15,6 +15,23 @@ COPY . .
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-dev
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
|
||||||
|
# the assets stage below.
|
||||||
|
RUN uv run python manage.py gen_element_types
|
||||||
|
|
||||||
|
|
||||||
|
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
|
||||||
|
# the compiled output ships in the image (dist/ is build-only, not committed).
|
||||||
|
FROM node:22-bookworm-slim AS assets
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
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 \
|
||||||
|
&& pnpm exec tsc
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim-bookworm
|
FROM python:3.14-slim-bookworm
|
||||||
|
|
||||||
@@ -44,6 +61,10 @@ WORKDIR /home/timetracker/app
|
|||||||
|
|
||||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||||
|
|
||||||
|
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
|
||||||
|
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
|
||||||
|
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
|
||||||
|
|
||||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ init:
|
|||||||
server:
|
server:
|
||||||
uv run python -Wa manage.py runserver
|
uv run python -Wa manage.py runserver
|
||||||
|
|
||||||
|
gen-element-types:
|
||||||
|
uv run python manage.py gen_element_types
|
||||||
|
|
||||||
|
ts: gen-element-types
|
||||||
|
pnpm exec tsc
|
||||||
|
|
||||||
|
ts-check: gen-element-types
|
||||||
|
pnpm exec tsc --noEmit
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@pnpm concurrently \
|
@pnpm concurrently \
|
||||||
--names "Django,Tailwind" \
|
--names "Django,Tailwind,TS" \
|
||||||
--prefix-colors "blue,green" \
|
--prefix-colors "blue,green,magenta" \
|
||||||
"uv run python -Wa manage.py runserver" \
|
"uv run python -Wa manage.py runserver" \
|
||||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||||
|
"pnpm exec tsc --watch"
|
||||||
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
@@ -85,7 +95,7 @@ format:
|
|||||||
format-check:
|
format-check:
|
||||||
uv run ruff format --check
|
uv run ruff format --check
|
||||||
|
|
||||||
check: lint format-check test
|
check: lint format-check ts-check test
|
||||||
|
|
||||||
date:
|
date:
|
||||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from common.components.core import (
|
|||||||
randomid,
|
randomid,
|
||||||
render,
|
render,
|
||||||
)
|
)
|
||||||
|
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,
|
||||||
@@ -47,7 +48,6 @@ from common.components.primitives import (
|
|||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
@@ -67,6 +67,7 @@ from common.components.primitives import (
|
|||||||
SimpleTable,
|
SimpleTable,
|
||||||
Span,
|
Span,
|
||||||
StaticScript,
|
StaticScript,
|
||||||
|
StyledButton,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableTd,
|
TableTd,
|
||||||
@@ -76,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 (
|
||||||
@@ -91,6 +93,9 @@ from common.utils import truncate
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"truncate",
|
"truncate",
|
||||||
"BaseComponent",
|
"BaseComponent",
|
||||||
|
"register_element",
|
||||||
|
"SessionTimestampButtons",
|
||||||
|
"custom_element_builder",
|
||||||
"Element",
|
"Element",
|
||||||
"Fragment",
|
"Fragment",
|
||||||
"Media",
|
"Media",
|
||||||
@@ -104,7 +109,7 @@ __all__ = [
|
|||||||
"randomid",
|
"randomid",
|
||||||
"A",
|
"A",
|
||||||
"AddForm",
|
"AddForm",
|
||||||
"Button",
|
"StyledButton",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"CsrfInput",
|
"CsrfInput",
|
||||||
|
|||||||
@@ -235,6 +235,16 @@ class Element(Node):
|
|||||||
children = [children]
|
children = [children]
|
||||||
self.children = children
|
self.children = children
|
||||||
|
|
||||||
|
def __getitem__(self, children: "Children | Node") -> "Element":
|
||||||
|
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
|
||||||
|
|
||||||
|
Returns an Element with the same tag/attributes/media and these
|
||||||
|
children, so the tree stays walkable (Media still bubbles)."""
|
||||||
|
items = children if isinstance(children, tuple) else (children,)
|
||||||
|
clone = Element(self.tag_name, self.attributes, list(items))
|
||||||
|
clone.media = self.media
|
||||||
|
return clone
|
||||||
|
|
||||||
def collect_media(self) -> Media:
|
def collect_media(self) -> Media:
|
||||||
media = self.media
|
media = self.media
|
||||||
for child in self.children:
|
for child in self.children:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Custom-element builder, registry, and TypeScript codegen.
|
||||||
|
|
||||||
|
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||||
|
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||||
|
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||||
|
is the single source of truth for the server<->client contract;
|
||||||
|
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||||
|
reader so drift fails ``tsc``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TypedDict, get_type_hints
|
||||||
|
|
||||||
|
from common.components.core import Media
|
||||||
|
from common.components.primitives import custom_element_builder
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ElementSpec:
|
||||||
|
tag: str # e.g. "game-status-selector"
|
||||||
|
ts_name: str # e.g. "GameStatusSelector"
|
||||||
|
props: type # a TypedDict subclass
|
||||||
|
|
||||||
|
|
||||||
|
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||||
|
|
||||||
|
|
||||||
|
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||||
|
"""Register an element so codegen can emit its TS contract."""
|
||||||
|
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||||
|
|
||||||
|
|
||||||
|
def _kebab(name: str) -> str:
|
||||||
|
return name.replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||||
|
|
||||||
|
|
||||||
|
def _camel(name: str) -> str:
|
||||||
|
head, *tail = name.split("_")
|
||||||
|
return head + "".join(part.title() for part in tail)
|
||||||
|
|
||||||
|
|
||||||
|
def _reader_expr(name: str, python_type: type) -> str:
|
||||||
|
attr = _kebab(name)
|
||||||
|
if python_type in (int, float):
|
||||||
|
return f'Number(el.getAttribute("{attr}"))'
|
||||||
|
if python_type is bool:
|
||||||
|
return f'el.getAttribute("{attr}") === "true"'
|
||||||
|
return f'el.getAttribute("{attr}") ?? ""'
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||||
|
hints = get_type_hints(spec.props)
|
||||||
|
interface_lines = "\n".join(
|
||||||
|
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||||
|
for name, python_type in hints.items()
|
||||||
|
)
|
||||||
|
reader_lines = "\n".join(
|
||||||
|
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||||
|
for name, python_type in hints.items()
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||||
|
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||||
|
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_props_module() -> str:
|
||||||
|
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||||
|
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||||
|
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||||
|
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatusSelectorProps(TypedDict):
|
||||||
|
game_id: int
|
||||||
|
status: str
|
||||||
|
csrf: str
|
||||||
|
|
||||||
|
|
||||||
|
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDeviceSelectorProps(TypedDict):
|
||||||
|
session_id: int
|
||||||
|
csrf: str
|
||||||
|
|
||||||
|
|
||||||
|
register_element(
|
||||||
|
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayEventRowProps(TypedDict):
|
||||||
|
game_id: int
|
||||||
|
csrf: str
|
||||||
|
api_create_url: str
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -17,7 +17,6 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
|||||||
``games/static/js/date_range_picker.js``.
|
``games/static/js/date_range_picker.js``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
|
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
|
||||||
from common.components.primitives import Div, Input, Span
|
from common.components.primitives import Div, Input, Span
|
||||||
from common.time import DatePartSpec, date_parts
|
from common.time import DatePartSpec, date_parts
|
||||||
@@ -101,9 +100,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _segment_input(
|
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
|
||||||
*, part: DatePartSpec, side: str, label: str, value: str
|
|
||||||
) -> Node:
|
|
||||||
side_label = "from" if side == "min" else "to"
|
side_label = "from" if side == "min" else "to"
|
||||||
return Input(
|
return Input(
|
||||||
attributes=[
|
attributes=[
|
||||||
|
|||||||
+92
-123
@@ -209,131 +209,100 @@ def PurchasePrice(purchase) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
_SELECTOR_MENU_CLASS = (
|
||||||
"""Alpine.js dropdown to change a game's status."""
|
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
||||||
options_html = "\n".join(
|
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
||||||
f"<template x-if=\"status == '{value}'\">"
|
"border border-gray-200 dark:border-gray-700"
|
||||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
)
|
||||||
f"</template>"
|
_SELECTOR_TOGGLE_CLASS = (
|
||||||
for value, label in game_statuses
|
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
||||||
)
|
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
||||||
list_items = "\n".join(
|
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
)
|
||||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
_SELECTOR_OPTION_CLASS = (
|
||||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
|
||||||
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
|
||||||
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
"dark:hover:text-white border-0"
|
||||||
f"</a></li>"
|
)
|
||||||
for value, label in game_statuses
|
|
||||||
)
|
|
||||||
|
|
||||||
return Safe(f"""
|
|
||||||
<div class="flex gap-2 items-center"
|
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||||
x-data="{{
|
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
||||||
status: '{game.status}',
|
from common.components.core import Element
|
||||||
status_display: '{game.get_status_display()}',
|
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
|
||||||
open: false,
|
from common.components.primitives import Li, Ul
|
||||||
saving: false,
|
|
||||||
setStatus(newStatus, newStatusDisplay) {{
|
options = [
|
||||||
this.status = newStatus;
|
Li()[
|
||||||
this.status_display = newStatusDisplay;
|
Element(
|
||||||
this.saving = true;
|
"button",
|
||||||
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
[
|
||||||
method: 'PATCH',
|
("type", "button"),
|
||||||
headers: {{
|
("data-option", ""),
|
||||||
'Content-Type': 'application/json',
|
("data-value", str(value)),
|
||||||
'X-CSRFToken': '{csrf_token}'
|
("class", _SELECTOR_OPTION_CLASS),
|
||||||
}},
|
],
|
||||||
body: JSON.stringify({{ status: newStatus }})
|
GameStatus(status=value, children=[label], display="flex"),
|
||||||
}})
|
)
|
||||||
.then(() => {{
|
]
|
||||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
for value, label in game_statuses
|
||||||
}})
|
]
|
||||||
.catch(() => {{
|
current_label = Span(data_label="")[
|
||||||
console.error('Failed to update status');
|
GameStatus(
|
||||||
}})
|
status=game.status,
|
||||||
.finally(() => this.saving = false);
|
children=[game.get_status_display()],
|
||||||
}}
|
display="flex",
|
||||||
}}">
|
)
|
||||||
{_dropdown_button_html(options_html, list_items)}
|
]
|
||||||
</div>
|
toggle = Element(
|
||||||
""")
|
"button",
|
||||||
|
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||||
|
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||||
|
current_label, Icon("arrowdown")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||||
|
dropdown = Div(
|
||||||
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||||
|
)[toggle, menu]
|
||||||
|
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:
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||||
"""Alpine.js dropdown to change a session's device."""
|
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
||||||
device_id = session.device_id or "null"
|
from common.components.core import Element
|
||||||
device_name = (session.device.name if session.device else "Unknown").replace(
|
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"
|
||||||
list_items = "\n".join(
|
options = [
|
||||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
Li()[
|
||||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
Element(
|
||||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
"button",
|
||||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
[
|
||||||
for d in session_devices
|
("type", "button"),
|
||||||
)
|
("data-option", ""),
|
||||||
|
("data-value", str(device.id)),
|
||||||
return Safe(f"""
|
("class", _SELECTOR_OPTION_CLASS),
|
||||||
<div class="flex gap-2 items-center"
|
],
|
||||||
x-data="{{
|
children=[device.name],
|
||||||
originalDeviceId: {device_id},
|
)
|
||||||
originalDeviceName: '{device_name}',
|
]
|
||||||
deviceId: {device_id},
|
for device in session_devices
|
||||||
deviceName: '{device_name}',
|
]
|
||||||
open: false,
|
toggle = Element(
|
||||||
saving: false,
|
"button",
|
||||||
setDevice(newDeviceId, newDeviceName) {{
|
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||||
this.deviceId = newDeviceId;
|
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||||
this.deviceName = newDeviceName;
|
Span(data_label="")[current_name], Icon("arrowdown")
|
||||||
this.saving = true;
|
],
|
||||||
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {{
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': '{csrf_token}'
|
|
||||||
}},
|
|
||||||
body: JSON.stringify({{ device_id: newDeviceId }})
|
|
||||||
}})
|
|
||||||
.then((res) => {{
|
|
||||||
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
|
||||||
}})
|
|
||||||
.catch(() => {{
|
|
||||||
this.deviceName = this.originalDeviceName;
|
|
||||||
this.deviceId = this.originalDeviceId;
|
|
||||||
console.error('Failed to update device');
|
|
||||||
}})
|
|
||||||
.finally(() => this.saving = false);
|
|
||||||
}}
|
|
||||||
}}">
|
|
||||||
{
|
|
||||||
_dropdown_button_html(
|
|
||||||
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
|
||||||
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
|
||||||
return (
|
|
||||||
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
|
||||||
'<button type="button" @click="open = !open" '
|
|
||||||
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
|
||||||
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
|
|
||||||
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
|
||||||
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
|
||||||
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
|
||||||
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
|
||||||
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
|
|
||||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
|
|
||||||
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
|
|
||||||
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
|
||||||
f"{list_items}"
|
|
||||||
"</ul>"
|
|
||||||
"</div>"
|
|
||||||
"</button>"
|
|
||||||
"</div>"
|
|
||||||
)
|
)
|
||||||
|
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||||
|
dropdown = Div(
|
||||||
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||||
|
)[toggle, menu]
|
||||||
|
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
||||||
|
Div(class_="flex gap-2 items-center")[dropdown]
|
||||||
|
]
|
||||||
|
|||||||
@@ -173,9 +173,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _enum_filter(
|
def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> Node:
|
||||||
field_name: str, options, choice: FilterChoice, *, nullable
|
|
||||||
) -> Node:
|
|
||||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
||||||
|
|
||||||
Enum fields are single-valued, so no M2M modifiers (all/only are
|
Enum fields are single-valued, so no M2M modifiers (all/only are
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -25,6 +24,7 @@ from common.components.core import (
|
|||||||
Safe,
|
Safe,
|
||||||
as_attributes,
|
as_attributes,
|
||||||
as_children,
|
as_children,
|
||||||
|
collect_media,
|
||||||
randomid,
|
randomid,
|
||||||
)
|
)
|
||||||
from common.icons import get_icon
|
from common.icons import get_icon
|
||||||
@@ -52,20 +52,53 @@ _SIZE_CLASSES = {
|
|||||||
# tag name is data, not a separate class/function body. Add a tag = one line.
|
# tag name is data, not a separate class/function body. Add a tag = one line.
|
||||||
|
|
||||||
|
|
||||||
def _html_element(tag_name: str):
|
def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
|
||||||
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
|
"""Translate htpy-style attribute kwargs to (name, value) pairs.
|
||||||
|
|
||||||
|
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
|
||||||
|
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
|
||||||
|
``False`` / ``None`` -> omitted."""
|
||||||
|
result: list[HTMLAttribute] = []
|
||||||
|
for key, value in attrs.items():
|
||||||
|
if value is None or value is False:
|
||||||
|
continue
|
||||||
|
name = key.rstrip("_").replace("_", "-")
|
||||||
|
result.append((name, name if value is True else value)) # type: ignore[arg-type]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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/<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,
|
||||||
children: Children = None,
|
children: Children = None,
|
||||||
|
**attrs: object,
|
||||||
) -> Element:
|
) -> Element:
|
||||||
return Element(tag_name, attributes, children)
|
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||||
|
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")
|
||||||
@@ -186,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",
|
||||||
@@ -227,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
|
||||||
@@ -650,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 [],
|
||||||
@@ -971,15 +977,26 @@ def SimpleTable(
|
|||||||
columns = columns or []
|
columns = columns or []
|
||||||
rows = rows or []
|
rows = rows or []
|
||||||
|
|
||||||
|
# Rows/header are stringified into the table markup, so their components'
|
||||||
|
# declared Media would be lost; collect it from the nodes first and attach
|
||||||
|
# it to the returned node so Page() still emits each cell component's JS
|
||||||
|
# (e.g. a <game-status-selector> in a cell).
|
||||||
|
media = Media()
|
||||||
|
|
||||||
header_html = ""
|
header_html = ""
|
||||||
if header_action:
|
if header_action:
|
||||||
header_html = str(TableHeader(children=[header_action]))
|
header_node = TableHeader(children=[header_action])
|
||||||
|
header_html = str(header_node)
|
||||||
|
media = media + collect_media(header_node)
|
||||||
|
|
||||||
columns_html = "".join(
|
columns_html = "".join(
|
||||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||||
for col in columns
|
for col in columns
|
||||||
)
|
)
|
||||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
row_nodes = [TableRow(data=row) for row in rows]
|
||||||
|
rows_html = "".join(str(node) for node in row_nodes)
|
||||||
|
for node in row_nodes:
|
||||||
|
media = media + collect_media(node)
|
||||||
|
|
||||||
pagination_html = ""
|
pagination_html = ""
|
||||||
if page_obj and elided_page_range:
|
if page_obj and elided_page_range:
|
||||||
@@ -995,7 +1012,8 @@ def SimpleTable(
|
|||||||
f"<tr>{columns_html}</tr></thead>"
|
f"<tr>{columns_html}</tr></thead>"
|
||||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||||
f"{rows_html}</tbody></table></div>"
|
f"{rows_html}</tbody></table></div>"
|
||||||
f"{pagination_html}</div>"
|
f"{pagination_html}</div>",
|
||||||
|
media=media,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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")` |
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
# synchronous operations inside the async context safely.
|
# synchronous operations inside the async context safely.
|
||||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def browser_type_launch_args(browser_type_launch_args):
|
def browser_type_launch_args(browser_type_launch_args):
|
||||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||||
|
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||||
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
|
page.fill('input[name="username"]', "tester")
|
||||||
|
page.fill('input[name="password"]', "secret123")
|
||||||
|
page.click('input[type="submit"]')
|
||||||
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
|
||||||
|
from games.models import Game, Platform
|
||||||
|
|
||||||
|
platform = Platform.objects.create(name="PC", icon="pc")
|
||||||
|
game = Game.objects.create(name="Test Game", platform=platform, status="u")
|
||||||
|
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
|
|
||||||
|
host = page.locator("game-status-selector").first
|
||||||
|
expect(host).to_be_attached()
|
||||||
|
host.locator("[data-toggle]").click()
|
||||||
|
expect(host.locator("[data-menu]")).to_be_visible()
|
||||||
|
with page.expect_response(
|
||||||
|
lambda r: "/status" in r.url and r.request.method == "PATCH"
|
||||||
|
):
|
||||||
|
host.locator('[data-option][data-value="f"]').click()
|
||||||
|
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||||
|
game.refresh_from_db()
|
||||||
|
assert game.status == "f"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_session_device_selector_patches(authenticated_page: Page, live_server):
|
||||||
|
from games.models import Device, Game, Platform, Session
|
||||||
|
|
||||||
|
platform = Platform.objects.create(name="PC", icon="pc")
|
||||||
|
game = Game.objects.create(name="Test Game", platform=platform)
|
||||||
|
desktop = Device.objects.create(name="Desktop")
|
||||||
|
deck = Device.objects.create(name="Deck")
|
||||||
|
session = Session.objects.create(
|
||||||
|
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
|
||||||
|
|
||||||
|
host = page.locator("session-device-selector").first
|
||||||
|
expect(host).to_be_attached()
|
||||||
|
host.locator("[data-toggle]").click()
|
||||||
|
with page.expect_response(
|
||||||
|
lambda r: "/device" in r.url and r.request.method == "PATCH"
|
||||||
|
):
|
||||||
|
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||||
|
session.refresh_from_db()
|
||||||
|
assert session.device_id == deck.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_play_event_row_increments(authenticated_page: Page, live_server):
|
||||||
|
from games.models import Game, Platform
|
||||||
|
|
||||||
|
platform = Platform.objects.create(name="PC", icon="pc")
|
||||||
|
game = Game.objects.create(name="Test Game", platform=platform)
|
||||||
|
|
||||||
|
page = authenticated_page
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
|
||||||
|
|
||||||
|
host = page.locator("play-event-row").first
|
||||||
|
expect(host).to_be_attached()
|
||||||
|
host.locator("[data-toggle]").click()
|
||||||
|
with page.expect_response(
|
||||||
|
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
|
||||||
|
):
|
||||||
|
host.locator("[data-add-play]").click()
|
||||||
|
expect(host.locator("[data-count]")).to_have_text("1")
|
||||||
|
assert game.playevents.count() == 1
|
||||||
@@ -121,9 +121,7 @@ def test_max_only_serializes_as_less_than(live_server, page):
|
|||||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
)
|
)
|
||||||
parsed = _filter_from_url(page.url)
|
parsed = _filter_from_url(page.url)
|
||||||
assert parsed == {
|
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
|
||||||
"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.
|
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -41,17 +37,17 @@ urlpatterns = [
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||||
page.goto(live_server.url + "/test-range-slider/")
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
# 1. Start with known state: Min is empty, Max is empty
|
# 1. Start with known state: Min is empty, Max is empty
|
||||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
# 2. Type "20" into max input
|
# 2. Type "20" into max input
|
||||||
max_input.fill("20")
|
max_input.fill("20")
|
||||||
|
|
||||||
# 3. Type "50" into min input (which is higher than 20)
|
# 3. Type "50" into min input (which is higher than 20)
|
||||||
min_input.fill("50")
|
min_input.fill("50")
|
||||||
|
|
||||||
# 4. Max input should have automatically synchronized/snapped to 50
|
# 4. Max input should have automatically synchronized/snapped to 50
|
||||||
assert max_input.input_value() == "50"
|
assert max_input.input_value() == "50"
|
||||||
|
|
||||||
@@ -60,16 +56,16 @@ def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||||
page.goto(live_server.url + "/test-range-slider/")
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
# 1. Type "50" into min input
|
# 1. Type "50" into min input
|
||||||
min_input.fill("50")
|
min_input.fill("50")
|
||||||
|
|
||||||
# 2. Type "30" into max input (which is less than 50)
|
# 2. Type "30" into max input (which is less than 50)
|
||||||
max_input.fill("30")
|
max_input.fill("30")
|
||||||
|
|
||||||
# 3. Min input should have automatically synchronized/snapped to 30
|
# 3. Min input should have automatically synchronized/snapped to 30
|
||||||
assert min_input.input_value() == "30"
|
assert min_input.input_value() == "30"
|
||||||
|
|
||||||
@@ -78,20 +74,20 @@ def test_range_slider_crossover_max_less_than_min(live_server, page):
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||||
page.goto(live_server.url + "/test-range-slider/")
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
# 1. Type value higher than dataMax (100 is max, type "150")
|
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||||
max_input.fill("150")
|
max_input.fill("150")
|
||||||
max_input.blur() # triggers "change" event
|
max_input.blur() # triggers "change" event
|
||||||
|
|
||||||
assert max_input.input_value() == "100"
|
assert max_input.input_value() == "100"
|
||||||
|
|
||||||
# 2. Type value lower than dataMin (0 is min, type "-20")
|
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||||
min_input.fill("-20")
|
min_input.fill("-20")
|
||||||
min_input.blur() # triggers "change" event
|
min_input.blur() # triggers "change" event
|
||||||
|
|
||||||
assert min_input.input_value() == "0"
|
assert min_input.input_value() == "0"
|
||||||
|
|
||||||
|
|
||||||
@@ -99,18 +95,20 @@ def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||||
page.goto(live_server.url + "/test-range-slider/")
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
# Locate handles
|
# Locate handles
|
||||||
max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]')
|
max_handle = page.locator(
|
||||||
|
'.range-handle-max[data-target="filter-session-count-max"]'
|
||||||
|
)
|
||||||
|
|
||||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||||
style = max_handle.get_attribute("style")
|
style = max_handle.get_attribute("style")
|
||||||
assert "left:100%" in style or "left: 100%" in style
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
|
|
||||||
# Set min to 50
|
# Set min to 50
|
||||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
min_input.fill("50")
|
min_input.fill("50")
|
||||||
|
|
||||||
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||||
style = max_handle.get_attribute("style")
|
style = max_handle.get_attribute("style")
|
||||||
assert "left:100%" in style or "left: 100%" in style
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ def prefilled_bar_view(request):
|
|||||||
"value": "Switch",
|
"value": "Switch",
|
||||||
"modifier": "INCLUDES",
|
"modifier": "INCLUDES",
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {"modifier": "IS_NULL"},
|
||||||
"modifier": "IS_NULL"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||||
@@ -63,19 +61,21 @@ def _filter_from_url(url: str) -> dict:
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
def test_string_filter_defaults_and_toggles(live_server, page):
|
def test_string_filter_defaults_and_toggles(live_server, page):
|
||||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||||
|
|
||||||
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
|
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
|
||||||
name_input = page.locator('input[name="filter-name"]')
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
assert name_input.is_enabled()
|
assert name_input.is_enabled()
|
||||||
|
|
||||||
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
|
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
|
||||||
assert is_radio.is_checked()
|
assert is_radio.is_checked()
|
||||||
|
|
||||||
# 2. Enter values, click "includes" (INCLUDES), and submit
|
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||||
name_input.fill("PlayStation")
|
name_input.fill("PlayStation")
|
||||||
includes_radio = page.locator('input[name="filter-name-modifier"][value="INCLUDES"]')
|
includes_radio = page.locator(
|
||||||
|
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||||
|
)
|
||||||
includes_radio.click()
|
includes_radio.click()
|
||||||
|
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"document.getElementById('filter-bar-form')"
|
"document.getElementById('filter-bar-form')"
|
||||||
@@ -92,15 +92,15 @@ def test_string_filter_null_states(live_server, page):
|
|||||||
|
|
||||||
name_input = page.locator('input[name="filter-name"]')
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
name_input.fill("Xbox")
|
name_input.fill("Xbox")
|
||||||
|
|
||||||
# Click "is null"
|
# Click "is null"
|
||||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||||
is_null_radio.click()
|
is_null_radio.click()
|
||||||
|
|
||||||
# Verification of interactive disabling
|
# Verification of interactive disabling
|
||||||
assert not name_input.is_enabled()
|
assert not name_input.is_enabled()
|
||||||
assert name_input.input_value() == ""
|
assert name_input.input_value() == ""
|
||||||
|
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"document.getElementById('filter-bar-form')"
|
"document.getElementById('filter-bar-form')"
|
||||||
@@ -114,19 +114,23 @@ def test_string_filter_null_states(live_server, page):
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
def test_string_filter_prefilled_states(live_server, page):
|
def test_string_filter_prefilled_states(live_server, page):
|
||||||
page.goto(live_server.url + "/test-string-filter-prefilled/")
|
page.goto(live_server.url + "/test-string-filter-prefilled/")
|
||||||
|
|
||||||
name_input = page.locator('input[name="filter-name"]')
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
group_input = page.locator('input[name="filter-group"]')
|
group_input = page.locator('input[name="filter-group"]')
|
||||||
|
|
||||||
# Verifies name matches "Switch" and "includes" is checked
|
# Verifies name matches "Switch" and "includes" is checked
|
||||||
assert name_input.input_value() == "Switch"
|
assert name_input.input_value() == "Switch"
|
||||||
assert name_input.is_enabled()
|
assert name_input.is_enabled()
|
||||||
assert page.locator('input[name="filter-name-modifier"][value="INCLUDES"]').is_checked()
|
assert page.locator(
|
||||||
|
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||||
|
).is_checked()
|
||||||
|
|
||||||
# Verifies group is empty, disabled, and "is null" is checked
|
# Verifies group is empty, disabled, and "is null" is checked
|
||||||
assert group_input.input_value() == ""
|
assert group_input.input_value() == ""
|
||||||
assert not group_input.is_enabled()
|
assert not group_input.is_enabled()
|
||||||
assert page.locator('input[name="filter-group-modifier"][value="IS_NULL"]').is_checked()
|
assert page.locator(
|
||||||
|
'input[name="filter-group-modifier"][value="IS_NULL"]'
|
||||||
|
).is_checked()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -136,11 +140,11 @@ def test_string_filter_deselect_re_enables(live_server, page):
|
|||||||
|
|
||||||
name_input = page.locator('input[name="filter-name"]')
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||||
|
|
||||||
# 1. Click "is null" -> disables input
|
# 1. Click "is null" -> disables input
|
||||||
is_null_radio.click()
|
is_null_radio.click()
|
||||||
assert not name_input.is_enabled()
|
assert not name_input.is_enabled()
|
||||||
|
|
||||||
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
|
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
|
||||||
is_null_radio.click()
|
is_null_radio.click()
|
||||||
assert name_input.is_enabled()
|
assert name_input.is_enabled()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+3
-1
@@ -412,7 +412,9 @@ class GameFilter(OperatorFilter):
|
|||||||
from games.models import PlayEvent
|
from games.models import PlayEvent
|
||||||
|
|
||||||
event_q = criterion.to_q("note")
|
event_q = criterion.to_q("note")
|
||||||
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
matching_ids = PlayEvent.objects.filter(event_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
return Q(id__in=matching_ids)
|
return Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
# Importing the components package triggers element registration at import time.
|
||||||
|
import common.components # noqa: F401
|
||||||
|
from common.components.custom_elements import render_props_module
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||||
|
|
||||||
|
def handle(self, *args, **options) -> None:
|
||||||
|
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = output_dir / "props.ts"
|
||||||
|
target.write_text(render_props_module(), encoding="utf-8")
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('games', '0017_add_filter_preset'),
|
("games", "0017_add_filter_preset"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='session',
|
model_name="session",
|
||||||
name='timestamp_start',
|
name="timestamp_start",
|
||||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
field=models.DateTimeField(db_index=True, verbose_name="Start"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-06-13 18:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0018_alter_session_timestamp_start"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="filterpreset",
|
||||||
|
name="mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("games", "Games"),
|
||||||
|
("sessions", "Sessions"),
|
||||||
|
("purchases", "Purchases"),
|
||||||
|
("playevents", "Play Events"),
|
||||||
|
("devices", "Devices"),
|
||||||
|
("platforms", "Platforms"),
|
||||||
|
],
|
||||||
|
default="games",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+10
-25
@@ -1790,6 +1790,9 @@
|
|||||||
.items-start {
|
.items-start {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.items-stretch {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
@@ -1963,17 +1966,10 @@
|
|||||||
border-top-right-radius: var(--radius-lg);
|
border-top-right-radius: var(--radius-lg);
|
||||||
border-bottom-right-radius: var(--radius-lg);
|
border-bottom-right-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.rounded-tr-md {
|
|
||||||
border-top-right-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.rounded-b {
|
.rounded-b {
|
||||||
border-bottom-right-radius: var(--radius);
|
border-bottom-right-radius: var(--radius);
|
||||||
border-bottom-left-radius: var(--radius);
|
border-bottom-left-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.rounded-b-md {
|
|
||||||
border-bottom-right-radius: var(--radius-md);
|
|
||||||
border-bottom-left-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.border {
|
.border {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -1982,10 +1978,6 @@
|
|||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 0px;
|
border-width: 0px;
|
||||||
}
|
}
|
||||||
.border-0\! {
|
|
||||||
border-style: var(--tw-border-style) !important;
|
|
||||||
border-width: 0px !important;
|
|
||||||
}
|
|
||||||
.border-2 {
|
.border-2 {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
@@ -2774,9 +2766,6 @@
|
|||||||
.line-through {
|
.line-through {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
}
|
}
|
||||||
.no-underline\! {
|
|
||||||
text-decoration-line: none !important;
|
|
||||||
}
|
|
||||||
.underline {
|
.underline {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
@@ -3162,6 +3151,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-gray-700 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-green-500 {
|
.hover\:bg-green-500 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -4200,17 +4196,6 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
|
||||||
& li:first-of-type a {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
|
|
||||||
& li:last-of-type a {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.\[\&_td\:last-child\]\:text-right {
|
.\[\&_td\:last-child\]\:text-right {
|
||||||
& td:last-child {
|
& td:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+76
-79
@@ -8,19 +8,18 @@ from django.middleware.csrf import get_token
|
|||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
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,11 +34,11 @@ from common.components import (
|
|||||||
Safe,
|
Safe,
|
||||||
SearchField,
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
|
StyledButton,
|
||||||
Ul,
|
Ul,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Li, P, Span, Strong
|
from common.components.primitives import Li, P, Span, Strong
|
||||||
from common.icons import get_icon
|
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
@@ -91,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",
|
||||||
@@ -173,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",
|
||||||
@@ -249,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",
|
||||||
@@ -340,69 +338,69 @@ _STAT_SVGS = {
|
|||||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||||
}
|
}
|
||||||
|
|
||||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
_PLAYED_BTN = (
|
||||||
<span class="uppercase">Played</span>
|
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||||
<a href="@@ADD_PE@@">
|
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
)
|
||||||
<span x-text="played"></span> times
|
_PLAYED_MENU = (
|
||||||
</button>
|
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||||
</a>
|
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
"border-gray-200 dark:border-gray-700"
|
||||||
@@ARROWDOWN@@
|
)
|
||||||
<div
|
|
||||||
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
|
||||||
x-show="open"
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
class=""
|
|
||||||
>
|
|
||||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
|
||||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
x-on:click="createPlayEvent"
|
|
||||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
|
||||||
>
|
|
||||||
Played times +1
|
|
||||||
</li>
|
|
||||||
<script>
|
|
||||||
function createPlayEvent() {
|
|
||||||
this.played++;
|
|
||||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
|
||||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
|
||||||
body: '{"game_id": @@GAME_ID@@}'
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.played--;
|
|
||||||
console.error('Failed to record play');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _played_row(game: Game, request: HttpRequest) -> Node:
|
def _played_row(game: Game, request: HttpRequest) -> Node:
|
||||||
"""The 'Played N times' control with its Alpine.js dropdown."""
|
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||||
replacements = {
|
from common.components import Element
|
||||||
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
from common.components.custom_elements import _PlayEventRow
|
||||||
"@@ADD_PE@@": reverse("games:add_playevent"),
|
from common.components.primitives import Button
|
||||||
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
|
||||||
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
played: int = 0
|
||||||
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
played = game.playevents.count()
|
||||||
"@@CSRF@@": get_token(request),
|
|
||||||
"@@GAME_ID@@": str(game.id),
|
count_button = A(href=reverse("games:add_playevent"))[
|
||||||
}
|
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
||||||
html = _PLAYED_ROW_TEMPLATE
|
Span(data_count="")[str(played)], " times"
|
||||||
for token, value in replacements.items():
|
]
|
||||||
html = html.replace(token, value)
|
]
|
||||||
return Safe(html)
|
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
||||||
|
Ul()[
|
||||||
|
Li(class_="px-4 py-2")[
|
||||||
|
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
|
||||||
|
"Add playthrough..."
|
||||||
|
]
|
||||||
|
],
|
||||||
|
Li(class_="px-4 py-2 cursor-pointer")[
|
||||||
|
Element(
|
||||||
|
"button",
|
||||||
|
[("type", "button"), ("data-add-play", "")],
|
||||||
|
children=["Played times +1"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
toggle = Element(
|
||||||
|
"button",
|
||||||
|
[
|
||||||
|
("type", "button"),
|
||||||
|
("data-toggle", ""),
|
||||||
|
("class", _PLAYED_BTN + " rounded-e-lg"),
|
||||||
|
],
|
||||||
|
[Icon("arrowdown")],
|
||||||
|
)
|
||||||
|
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
|
||||||
|
# contain another <button>, and that invalid nesting makes the HTML parser
|
||||||
|
# close ancestors early, ejecting later page sections from their container.
|
||||||
|
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
|
||||||
|
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
|
||||||
|
count_button, toggle_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:
|
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||||
@@ -686,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",
|
||||||
@@ -698,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",
|
||||||
@@ -784,7 +781,7 @@ def _history_section(game: Game) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_GET_SESSION_COUNT_SCRIPT = mark_safe(
|
_GET_SESSION_COUNT_SCRIPT = Safe(
|
||||||
"<script>\n"
|
"<script>\n"
|
||||||
" function getSessionCount() {\n"
|
" function getSessionCount() {\n"
|
||||||
" return document.getElementById('session-count')"
|
" return document.getElementById('session-count')"
|
||||||
|
|||||||
@@ -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]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"npm-check-updates": "^16.14.20",
|
"npm-check-updates": "^16.14.20",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
|||||||
Generated
+3329
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import unittest
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from common.components import custom_element_builder, render
|
||||||
|
from common.components.custom_elements import (
|
||||||
|
ElementSpec,
|
||||||
|
_ts_for_spec,
|
||||||
|
register_element,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SampleProps(TypedDict):
|
||||||
|
game_id: int
|
||||||
|
status: str
|
||||||
|
is_on: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CustomElementBuilderTest(unittest.TestCase):
|
||||||
|
def test_serializes_props_to_kebab_attributes(self):
|
||||||
|
x_sample = custom_element_builder("x-sample")
|
||||||
|
html = render(x_sample(game_id=3, status="f")["hi"])
|
||||||
|
self.assertIn("<x-sample", html)
|
||||||
|
self.assertIn('game-id="3"', html)
|
||||||
|
self.assertIn('status="f"', html)
|
||||||
|
self.assertIn(">hi</x-sample>", html)
|
||||||
|
|
||||||
|
def test_declares_compiled_module_media(self):
|
||||||
|
from common.components import collect_media
|
||||||
|
|
||||||
|
x_sample = custom_element_builder("x-sample")
|
||||||
|
node = x_sample(game_id=3)
|
||||||
|
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
||||||
|
|
||||||
|
|
||||||
|
class CodegenTest(unittest.TestCase):
|
||||||
|
def test_emits_interface_and_reader(self):
|
||||||
|
spec = ElementSpec("x-sample", "XSample", SampleProps)
|
||||||
|
ts = _ts_for_spec(spec)
|
||||||
|
self.assertIn("export interface XSampleProps {", ts)
|
||||||
|
self.assertIn("gameId: number;", ts)
|
||||||
|
self.assertIn("status: string;", ts)
|
||||||
|
self.assertIn("isOn: boolean;", ts)
|
||||||
|
self.assertIn(
|
||||||
|
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
|
||||||
|
)
|
||||||
|
self.assertIn('Number(el.getAttribute("game-id"))', ts)
|
||||||
|
self.assertIn('el.getAttribute("status") ?? ""', ts)
|
||||||
|
self.assertIn('el.getAttribute("is-on") === "true"', ts)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryTest(unittest.TestCase):
|
||||||
|
def test_register_adds_spec(self):
|
||||||
|
from common.components.custom_elements import ELEMENT_REGISTRY
|
||||||
|
|
||||||
|
before = len(ELEMENT_REGISTRY)
|
||||||
|
register_element("x-reg-test", "XRegTest", SampleProps)
|
||||||
|
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
||||||
|
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatusSelectorRenderTest(unittest.TestCase):
|
||||||
|
def test_emits_tag_props_and_media(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from common.components import GameStatusSelector, collect_media, render
|
||||||
|
|
||||||
|
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
|
||||||
|
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
|
||||||
|
html = render(node)
|
||||||
|
self.assertIn("<game-status-selector", html)
|
||||||
|
self.assertIn('game-id="7"', html)
|
||||||
|
self.assertIn('status="f"', html)
|
||||||
|
self.assertIn('csrf="tok"', html)
|
||||||
|
self.assertIn("data-option", html)
|
||||||
|
self.assertIn('data-value="u"', html)
|
||||||
|
self.assertNotIn("x-data", html) # no Alpine left
|
||||||
|
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDeviceSelectorRenderTest(unittest.TestCase):
|
||||||
|
def test_emits_tag_and_options(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from common.components import SessionDeviceSelector, render
|
||||||
|
|
||||||
|
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
|
||||||
|
devices = [
|
||||||
|
SimpleNamespace(id=1, name="Desktop"),
|
||||||
|
SimpleNamespace(id=2, name="Deck"),
|
||||||
|
]
|
||||||
|
html = render(SessionDeviceSelector(session, devices, "tok"))
|
||||||
|
self.assertIn("<session-device-selector", html)
|
||||||
|
self.assertIn('session-id="4"', html)
|
||||||
|
self.assertIn('data-value="2"', html)
|
||||||
|
self.assertNotIn("x-data", html)
|
||||||
@@ -362,4 +362,3 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertIn('name="filter-refunded"', purchase_html)
|
self.assertIn('name="filter-refunded"', purchase_html)
|
||||||
self.assertIn('value="true"', purchase_html)
|
self.assertIn('value="true"', purchase_html)
|
||||||
self.assertIn('value="false"', purchase_html)
|
self.assertIn('value="false"', purchase_html)
|
||||||
|
|
||||||
|
|||||||
@@ -85,4 +85,3 @@ class ParseBoolNullableTest(SimpleTestCase):
|
|||||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -560,8 +560,14 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json=""))
|
html = str(FilterBar(filter_json=""))
|
||||||
assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
assert (
|
||||||
assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||||
|
not in html
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||||
|
not in html
|
||||||
|
)
|
||||||
|
|
||||||
def test_mastered_checked_when_filtered(self):
|
def test_mastered_checked_when_filtered(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -784,7 +790,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.filters import SessionFilter
|
from games.filters import SessionFilter
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
data = self._setup_entities()
|
self._setup_entities()
|
||||||
|
|
||||||
# Test duration_total_hours equals 4
|
# Test duration_total_hours equals 4
|
||||||
sf_tot = SessionFilter.from_json(
|
sf_tot = SessionFilter.from_json(
|
||||||
@@ -808,7 +814,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.filters import PurchaseFilter
|
from games.filters import PurchaseFilter
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
data = self._setup_entities()
|
self._setup_entities()
|
||||||
|
|
||||||
pf = PurchaseFilter.from_json(
|
pf = PurchaseFilter.from_json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,5 +174,48 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
self.assertIn("range_slider.js", media.js)
|
self.assertIn("range_slider.js", media.js)
|
||||||
|
|
||||||
|
|
||||||
|
class HtpyStyleSugarTest(unittest.TestCase):
|
||||||
|
def test_getitem_sets_children(self):
|
||||||
|
from common.components import Div, Span
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
render(Div(class_="card")[Span()["hi"]]),
|
||||||
|
'<div class="card"><span>hi</span></div>',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_getitem_multiple_children(self):
|
||||||
|
from common.components import Div
|
||||||
|
|
||||||
|
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
|
||||||
|
|
||||||
|
def test_kwargs_class_underscore_becomes_class(self):
|
||||||
|
from common.components import Div
|
||||||
|
|
||||||
|
self.assertIn('class="x"', render(Div(class_="x")))
|
||||||
|
|
||||||
|
def test_kwargs_inner_underscore_becomes_hyphen(self):
|
||||||
|
from common.components import Div
|
||||||
|
|
||||||
|
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
|
||||||
|
|
||||||
|
def test_kwargs_true_renders_bare_attr(self):
|
||||||
|
from common.components import Div
|
||||||
|
|
||||||
|
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
|
||||||
|
|
||||||
|
def test_kwargs_false_and_none_omitted(self):
|
||||||
|
from common.components import Div
|
||||||
|
|
||||||
|
html = render(Div(hidden=False, title=None))
|
||||||
|
self.assertNotIn("hidden", html)
|
||||||
|
self.assertNotIn("title", html)
|
||||||
|
|
||||||
|
def test_getitem_preserves_media(self):
|
||||||
|
from common.components import Div, Media, collect_media
|
||||||
|
|
||||||
|
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
|
||||||
|
self.assertEqual(collect_media(node).js, ("a.js",))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
"Platform",
|
"Platform",
|
||||||
'id="history-container"',
|
'id="history-container"',
|
||||||
"status-changed from:body",
|
"status-changed from:body",
|
||||||
"createPlayEvent", # the played-row Alpine dropdown script
|
"<play-event-row", # the played-row custom element
|
||||||
'hx-target="#global-modal-container"', # delete trigger
|
'hx-target="#global-modal-container"', # delete trigger
|
||||||
"Purchases",
|
"Purchases",
|
||||||
"Sessions",
|
"Sessions",
|
||||||
@@ -179,6 +179,14 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||||
|
|
||||||
|
def test_view_game_uses_play_event_row_element(self):
|
||||||
|
game = Game.objects.create(name="Played Game", platform=self.platform)
|
||||||
|
html = self.get("games:view_game", game.id).content.decode()
|
||||||
|
self.assertIn("<play-event-row", html)
|
||||||
|
self.assertIn('game-id="', html)
|
||||||
|
self.assertNotIn("@@", html) # token-replace hack gone
|
||||||
|
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
|
||||||
|
|
||||||
def test_view_game_empty_sections(self):
|
def test_view_game_empty_sections(self):
|
||||||
"""A game with no sessions/purchases/etc shows the empty messages."""
|
"""A game with no sessions/purchases/etc shows the empty messages."""
|
||||||
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface DropdownConfig {
|
||||||
|
patchUrl: string;
|
||||||
|
bodyKey: string; // server field name, e.g. "status" or "device_id"
|
||||||
|
event: string; // dispatched on document.body after a successful PATCH
|
||||||
|
csrf: string;
|
||||||
|
numericValue?: boolean; // parse the option value as a number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires a light-DOM value-selector dropdown that lives inside `host`.
|
||||||
|
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
|
||||||
|
// [data-label], and one or more [data-option][data-value].
|
||||||
|
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
|
||||||
|
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
|
||||||
|
const menu = host.querySelector<HTMLElement>("[data-menu]");
|
||||||
|
const label = host.querySelector<HTMLElement>("[data-label]");
|
||||||
|
if (!toggle || !menu || !label) return;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!host.contains(event.target as Node)) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
|
||||||
|
option.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const raw = option.dataset.value ?? "";
|
||||||
|
label.innerHTML = option.innerHTML;
|
||||||
|
close();
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.fetchWithHtmxTriggers(config.patchUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
|
||||||
|
.catch(() => console.error("Failed to update", config.patchUrl));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { readGameStatusSelectorProps } from "../generated/props.js";
|
||||||
|
import { initDropdown } from "./dropdown.js";
|
||||||
|
|
||||||
|
class GameStatusSelectorElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
const props = readGameStatusSelectorProps(this);
|
||||||
|
initDropdown(this, {
|
||||||
|
patchUrl: `/api/games/${props.gameId}/status`,
|
||||||
|
bodyKey: "status",
|
||||||
|
event: "status-changed",
|
||||||
|
csrf: props.csrf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("game-status-selector", GameStatusSelectorElement);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readPlayEventRowProps } from "../generated/props.js";
|
||||||
|
|
||||||
|
class PlayEventRowElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
const props = readPlayEventRowProps(this);
|
||||||
|
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
|
||||||
|
const menu = this.querySelector<HTMLElement>("[data-menu]");
|
||||||
|
const count = this.querySelector<HTMLElement>("[data-count]");
|
||||||
|
const addPlay = this.querySelector<HTMLElement>("[data-add-play]");
|
||||||
|
if (!toggle || !menu) return;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
};
|
||||||
|
toggle.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!this.contains(event.target as Node)) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
addPlay?.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (count) count.textContent = String(Number(count.textContent) + 1);
|
||||||
|
close();
|
||||||
|
window
|
||||||
|
.fetchWithHtmxTriggers(props.apiCreateUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
|
||||||
|
body: JSON.stringify({ game_id: props.gameId }),
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (count) count.textContent = String(Number(count.textContent) - 1);
|
||||||
|
console.error("Failed to record play");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("play-event-row", PlayEventRowElement);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { readSessionDeviceSelectorProps } from "../generated/props.js";
|
||||||
|
import { initDropdown } from "./dropdown.js";
|
||||||
|
|
||||||
|
class SessionDeviceSelectorElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
const props = readSessionDeviceSelectorProps(this);
|
||||||
|
initDropdown(this, {
|
||||||
|
patchUrl: `/api/session/${props.sessionId}/device`,
|
||||||
|
bodyKey: "device_id",
|
||||||
|
event: "device-changed",
|
||||||
|
csrf: props.csrf,
|
||||||
|
numericValue: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("session-device-selector", SessionDeviceSelectorElement);
|
||||||
@@ -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);
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"rootDir": "ts",
|
||||||
|
"outDir": "games/static/js/dist"
|
||||||
|
},
|
||||||
|
"include": ["ts/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user