diff --git a/.env.example b/.env.example index ddac900..ccd3166 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data # CSRF trusted origins CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz + +# Create a default admin/admin superuser on startup (for initial setup only) +CREATE_DEFAULT_SUPERUSER=false diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 10530f8..26edc96 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -19,6 +19,17 @@ jobs: - name: Install dependencies 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 run: uv run playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index 851e426..1b8c576 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ __pycache__ .venv/ node_modules package-lock.json -pnpm-lock.yaml db.sqlite3 data/ /static/ @@ -13,3 +12,8 @@ dist/ .python-version .direnv .hermes/ + +# Build artifacts: generated in CI/Docker assets stage, not committed +/games/static/base.css +/games/static/js/dist/ +/ts/generated/ diff --git a/CLAUDE.md b/CLAUDE.md index 6456fa5..231f07e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, …) - **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/.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 ` - - - - -""" +_PLAYED_BTN = ( + "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 " + "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " + "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer" +) +_PLAYED_MENU = ( + "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" +) def _played_row(game: Game, request: HttpRequest) -> Node: - """The 'Played N times' control with its Alpine.js dropdown.""" - replacements = { - "@@PLAYED_COUNT@@": str(game.playevents.count()), - "@@ADD_PE@@": reverse("games:add_playevent"), - "@@ARROWDOWN@@": get_icon("arrowdown"), - "@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]), - "@@API_CREATE@@": reverse("api-1.0.0:create_playevent"), - "@@CSRF@@": get_token(request), - "@@GAME_ID@@": str(game.id), - } - html = _PLAYED_ROW_TEMPLATE - for token, value in replacements.items(): - html = html.replace(token, value) - return Safe(html) + """'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" + from common.components import Element + from common.components.custom_elements import _PlayEventRow + from common.components.primitives import Button + + played: int = 0 + played = game.playevents.count() + + count_button = A(href=reverse("games:add_playevent"))[ + Button(class_=_PLAYED_BTN + " rounded-s-lg")[ + Span(data_count="")[str(played)], " times" + ] + ] + 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