Compare commits
7 commits
706fe745d3
...
617e3ef16d
| Author | SHA1 | Date | |
|---|---|---|---|
| 617e3ef16d | |||
| 5ac6633d40 | |||
| f87480e79d | |||
| 003f38ea60 | |||
| e2a60e71a3 | |||
| f74ee43cb4 | |||
| 3ad2766f82 |
35
.dockerignore
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Version control
|
||||
.git
|
||||
.forgejo
|
||||
|
||||
# Data volume (never bake in runtime data)
|
||||
data/
|
||||
|
||||
# Test artefacts (Playwright)
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
**/visuals/
|
||||
|
||||
# Build artifacts and caches
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.angular/
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/.venv/
|
||||
|
||||
# Docs (the API spec is the runtime contract — but the image doesn't need it)
|
||||
docs/
|
||||
*.md
|
||||
README.md
|
||||
|
||||
# Secrets — never bake into images
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
secrets/
|
||||
|
||||
# OS artefacts
|
||||
.DS_Store
|
||||
157
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run pytest -v
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
frontend-test:
|
||||
name: Frontend unit tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest
|
||||
run: npm test
|
||||
|
||||
frontend-build:
|
||||
name: Frontend build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
e2e:
|
||||
name: Playwright e2e
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend-build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Start stack
|
||||
run: docker compose -p life-towers -f docker-compose.dev.yml up --build -d
|
||||
|
||||
- name: Wait for /api/v1/health
|
||||
run: |
|
||||
set -e
|
||||
cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers)
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
|
||||
if [ "$status" = healthy ]; then
|
||||
echo "stack healthy after ${i} attempts"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "stack failed to become healthy" >&2
|
||||
docker compose -p life-towers -f docker-compose.dev.yml logs >&2
|
||||
exit 1
|
||||
|
||||
- name: Run Playwright
|
||||
run: |
|
||||
docker run --rm \
|
||||
--network life-towers_default \
|
||||
-v "$(pwd)/frontend:/work" \
|
||||
-w /work \
|
||||
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
|
||||
-e CI=true \
|
||||
mcr.microsoft.com/playwright:v1.60.0-noble \
|
||||
sh -c 'npm ci && npx playwright test'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload visual screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-visuals
|
||||
path: frontend/visuals
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -p life-towers -f docker-compose.dev.yml logs
|
||||
|
||||
- name: Tear down stack
|
||||
if: always()
|
||||
run: docker compose -p life-towers -f docker-compose.dev.yml down -v
|
||||
50
.forgejo/workflows/docker.yml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }}
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
49
.gitignore
vendored
Executable file → Normal file
|
|
@ -1,48 +1 @@
|
|||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Only exists if Bazel was run
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events.json
|
||||
speed-measure-plugin.json
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.firebase
|
||||
**/__pycache__
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"singleQuote": true,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
291
CLAUDE.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# Life Towers — agent notes
|
||||
|
||||
This file captures the load-bearing things to know about this codebase. Read it before making non-trivial changes.
|
||||
|
||||
## What this is
|
||||
|
||||
A personal-productivity TODO app where each user has multiple **pages**, each page holds several **towers** (vertical task columns), and each tower has **blocks** (atomic tasks). Pending blocks live in a **tasks accordion** at the top of the tower; completed ones fall into the tower as colored squares (the "falling animation" is the defining visual). Date-range slider filters which done blocks are visible.
|
||||
|
||||
This is a port/modernisation of a legacy Angular 7 app. **Design parity with the legacy is a hard requirement** — the user cares deeply about the original aesthetic. The legacy source lives at `_legacy_reference/` (gitignored) and is the source of truth for any visual question. A detailed style guide is at `docs/DESIGN.md`.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
backend/ # FastAPI + SQLite (WAL)
|
||||
src/life_towers/
|
||||
main.py # ASGI app, lifespan, static mount + SPA fallback
|
||||
api.py # Routes (/api/v1/{health,register,data})
|
||||
auth.py # Bearer UUIDv4 → user_id
|
||||
db.py # sqlite3 factory + migration runner
|
||||
limits.py # slowapi limiter + payload-size middleware
|
||||
models.py # pydantic v2 schemas
|
||||
logging.py # structlog JSON
|
||||
migrations/ # 001_initial.sql consolidated schema
|
||||
tests/test_api.py # pytest + httpx AsyncClient
|
||||
pyproject.toml # uv-managed
|
||||
frontend/ # Angular 21+ standalone, signals, zoneless
|
||||
src/app/
|
||||
components/
|
||||
pages/ # Top-level: page selector + Settings button
|
||||
page/ # Tower row + slider + trash zone + confirm-delete
|
||||
tower/ # White tower card: tasks accordion + add-block + falling stack + name input
|
||||
block/ # Colored square (1/6 tower width)
|
||||
tasks/ # Pending-blocks accordion with tickbox
|
||||
welcome/ # Zero-state intro modal + "Try an example"
|
||||
modal/ # Generic backdrop shell + sub-modals (block-edit carousel, settings, tower-settings)
|
||||
shared/ # select-add, toggle, double-slider, color-picker, icon
|
||||
services/
|
||||
api.service.ts # HttpClient wrapper, exact API contract
|
||||
store.service.ts # signal-based store, debounced PUT, retry, loadExample
|
||||
modal-state.service.ts # global open-modal counter (drag locking)
|
||||
models/index.ts # Page / Tower / Block / HslColor TS interfaces
|
||||
utils/{color,hash}.ts
|
||||
library/ # Legacy SCSS dropped in verbatim — DO NOT REFACTOR
|
||||
public/assets/ # SVG icons (arrow, pen, plus-sign, trash, x-sign)
|
||||
src/assets/fonts/ # Self-hosted woff2 (Open Sans Condensed, Raleway, …)
|
||||
e2e/ # Playwright (smoke + visuals)
|
||||
docs/
|
||||
api-spec.md # Single source of truth for the HTTP API contract
|
||||
DESIGN.md # Legacy visual spec (verbatim SCSS quotes)
|
||||
_legacy_reference/ # Original Angular 7 source — read-only, gitignored
|
||||
Dockerfile # Multi-stage: node:22-alpine build → python:3.13-slim runtime
|
||||
docker-compose.yml # Production single-container
|
||||
docker-compose.dev.yml # Ephemeral volume for Playwright runs
|
||||
.forgejo/workflows/ # CI + deploy
|
||||
```
|
||||
|
||||
## Tech stack quickrefs
|
||||
|
||||
- **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap)
|
||||
- **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps
|
||||
- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`
|
||||
- **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`)
|
||||
|
||||
## Build / dev / test / deploy
|
||||
|
||||
```bash
|
||||
# Full local stack (ephemeral data)
|
||||
docker compose -f docker-compose.dev.yml up --build -d # http://localhost:8000
|
||||
docker compose -f docker-compose.dev.yml down -v # teardown
|
||||
|
||||
# Frontend dev (with backend proxied via proxy.conf.json)
|
||||
cd frontend && npm start # ng serve on :4200, /api → :8000
|
||||
cd frontend && npm run build # production bundle to dist/frontend/browser/
|
||||
cd frontend && npm test # vitest (--run already in the script)
|
||||
cd frontend && npm run test:e2e # Playwright
|
||||
|
||||
# Backend dev
|
||||
cd backend && uv sync
|
||||
cd backend && uv run pytest -v
|
||||
cd backend && uv run uvicorn life_towers.main:app --reload # :8000
|
||||
|
||||
# E2E in the sandbox: host-to-container port forwarding is broken here.
|
||||
# Run Playwright INSIDE a container on the docker network instead:
|
||||
docker run --rm \
|
||||
--network life-towers_default \
|
||||
-v "$(pwd)/frontend:/work" -w /work \
|
||||
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
|
||||
mcr.microsoft.com/playwright:v1.60.0-noble \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Design system — the legacy is the source of truth
|
||||
|
||||
`docs/DESIGN.md` is the comprehensive spec. Key tokens:
|
||||
|
||||
```scss
|
||||
// _legacy_reference/frontend/src/library/common-variables.scss
|
||||
$accent-color: #a2666f; // rose
|
||||
$text-color: #5d576b; // muted purple-grey (also iOS theme-color)
|
||||
$light-color: #ffffff;
|
||||
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%); // 50% alpha — modal backdrop
|
||||
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%); // body background
|
||||
$shadow: 0 0 1.5px 1.5px rgba(0,0,0,0.1), 0 0 3px 2px rgba(0,0,0,0.05);
|
||||
$shadow-border: 0 0 0 0.75px rgba(0,0,0,0.1); // hairline
|
||||
$normal-font: 'Open Sans Condensed', sans-serif;
|
||||
$title-font: 'Raleway', serif;
|
||||
$mobile-width: 520px;
|
||||
```
|
||||
|
||||
Spacing tokens (`library/main.scss`):
|
||||
```scss
|
||||
:root {
|
||||
--large-padding: 30px; // 20px on mobile
|
||||
--medium-padding: 15px;
|
||||
--small-padding: 10px; // 7.5px on mobile
|
||||
--border-radius: 5px; // 3px on mobile
|
||||
}
|
||||
```
|
||||
|
||||
Font sizes (`library/text.scss`): `--larger/large/medium/small-font-size` = `22/18/16/11` desktop, `20/16/14/10` mobile.
|
||||
|
||||
**Animation timings**:
|
||||
- `$long-animation-time: 200ms` — opacity, hover transforms, modal entry
|
||||
- `$short-animation-time: 100ms` — tighter transitions (red trash-highlight overlay)
|
||||
- **Falling animation**: `transform 1.5s cubic-bezier(0.5, 0, 1, 0)` (gravity ease-in)
|
||||
- **Modal opacity entry/exit**: `300ms`
|
||||
|
||||
## Architectural conventions to follow
|
||||
|
||||
### Components
|
||||
|
||||
- Every component is **standalone**, **OnPush**, with `signal()`-based local state. No NgModule.
|
||||
- Templates use **`@if` / `@for` / `@switch`** — never `*ngIf`/`*ngFor`.
|
||||
- Inline templates + inline styles in `@Component({ template: \`...\`, styles: \`...\` })` is the norm. Larger components use templateUrl + styleUrl (only `pages.component`, `page.component`).
|
||||
- SCSS inside `styles:` template literal — **`//` comments are fine inside SCSS but watch for backticks**; `// `display: contents`` inside a template literal closes it early. Use `/* */` if you need to mention CSS strings in comments.
|
||||
- Library files (`library/*.scss`) are dropped from the legacy verbatim. Don't refactor them. Components `@import '../../../library/main'` (or similar relative depth).
|
||||
|
||||
### State
|
||||
|
||||
- `StoreService` is the single source of truth (signal-based).
|
||||
- Mutations update the signal immediately (**optimistic**) and call `scheduleSave()` which debounces 750ms and PUTs the full tree.
|
||||
- Failure mode: exponential backoff up to 5 attempts (1s, 2s, 4s, 8s, 16s).
|
||||
- LocalStorage cache key: `life-towers.cache.v4`. Token: `life-towers.token.v4`.
|
||||
- `init()` flow: stored token? Use it. No token? Mint via `uuidV4()` → register → GET data. On 401: re-register the SAME token (idempotent server-side), never silently mint a fresh one (would orphan data).
|
||||
|
||||
### Reactivity caveats with zoneless
|
||||
|
||||
- Plain field mutations in event handlers (`(click)="x = true"`) still trigger CD because Angular's event manager marks the view dirty — even with zoneless. But any async mutation outside an Angular event (setTimeout, raw addEventListener, MutationObserver) **will not** trigger CD; use signals there.
|
||||
- `effect()` running on `signal()` reads triggers re-runs; wrap writes in `untracked()` to avoid loops.
|
||||
- For input-driven derived state that the user can also override (e.g. `tasks.expanded` seeded from `initiallyOpen` but also clickable), use either `linkedSignal` or `effect()` + a flag (the codebase uses the latter for `tasks.component`).
|
||||
|
||||
### Sync model
|
||||
|
||||
- **Tree-replace**: `PUT /api/v1/data` sends the entire user hierarchy atomically. Backend wraps in `BEGIN IMMEDIATE`, deletes existing pages (cascading to towers + blocks via FK), inserts new rows. `position` columns track ordering.
|
||||
- **No granular endpoints** for individual entity CRUD. Keep it tree-replace.
|
||||
- Spec is in `docs/api-spec.md`. Backend pydantic models match it. Frontend `models/index.ts` matches it. Field names are **snake_case** on both sides.
|
||||
|
||||
### Backend
|
||||
|
||||
- All endpoints are inside `APIRouter(prefix="/api/v1")`. Spec drives behavior — if you change a limit, update both spec and code.
|
||||
- Migrations: package data under `src/life_towers/migrations/`, loaded via `importlib.resources.files("life_towers").joinpath("migrations")`. The runner tracks applied state in a `schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER)` table.
|
||||
- All sqlite connections must do `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000`. The `get_connection()` factory does this.
|
||||
- Errors → JSON `{"error": code, "detail": str}` via a single `HTTPException` handler in `main.py`. Stack traces never leak (logged server-side).
|
||||
- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token.
|
||||
|
||||
## Visual + interaction details that bit me
|
||||
|
||||
### Falling animation (tower.component)
|
||||
|
||||
The `.block-container` has `transform: scaleY(-1)` so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at `translateY(500%)` via a `*` rule; the inline `[style.transform]="b._transform"` binding overrides per-block.
|
||||
|
||||
When exactly **one** new done block is added, the `reconcile()` method:
|
||||
1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen)
|
||||
2. Calls `requestAnimationFrame` → `requestAnimationFrame` to let the browser paint the initial state
|
||||
3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires
|
||||
|
||||
**Critical**: `grewByOne` detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original `tower.blocks` index, not appended. Use the new ID, not `styled[length-1]`.
|
||||
|
||||
The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. `prevDoneIds` tracks the full `allDone` array — not the filtered styled list — so range expansions don't mis-fire as "new block".
|
||||
|
||||
### Block-edit carousel (modal/block-edit.component)
|
||||
|
||||
- Lives at `position: fixed; z-index: 10001` to escape the modal dialog wrapper and cover the viewport
|
||||
- Two placeholder cards flank the real cards so the active card can fully center via `scroll-snap-align: center`
|
||||
- `.mask` overlay on non-active cards has three tiers: `active opacity 0`, `near-active 0.55`, default `1`. Card opacity also tiered (1 / 0.85 / 0.6) mimicking the legacy `1.33*(1-t/2)` curve
|
||||
- Backdrop click (anywhere not a non-placeholder card) closes the modal
|
||||
- Delete on an existing card **does not** close the modal — it stays open and the card re-renders out of the list
|
||||
- Auto-save on tag/toggle change; description deferred to blur
|
||||
|
||||
### Select-add (shared/select-add)
|
||||
|
||||
- Has a `.top` chip and a `.bottom` slide-down panel. `:has(.bottom.open) .top, .background { border-radius: var(--border-radius) var(--border-radius) 0 0 }` squares the bottom corners when open so the chip and panel read as one card
|
||||
- Shadow seam between chip + panel solved with `clip-path: inset(...)`: `.background.active` clips bottom (`inset(-6px -6px 0 -6px)`), `.bottom.open` clips top (`inset(0 -6px -6px -6px)`). 6px > the total $shadow spread (5px) so neither edge bleeds across the seam
|
||||
- Closing animation requires a two-transition setup: default state has `transition: ... visibility 0s $long-animation-time` (visibility delays on close), `.open` overrides with `visibility 0s 0s` (instant on open)
|
||||
|
||||
### Modal shell (modal/modal.component)
|
||||
|
||||
- `:host { display: contents }` — critical. Without this, the `lt-modal` host element takes a flex slot in `pages.component`'s `inner-spacing` layout, pushing the Settings button up when the modal mounts
|
||||
- `section.modal` is `position: fixed; z-index: 10000` with `transition: opacity 300ms`. The component flips `active = true` in `ngAfterViewInit` via `setTimeout(0)` so the opacity 0 → 1 transition runs
|
||||
- `ModalStateService.open()` / `.close()` are called in `AfterViewInit`/`OnDestroy`. `page.component` reads `modalState.anyOpen` and binds it to every tower's `[cdkDragDisabled]` so users can't drag towers behind an open modal
|
||||
|
||||
### Carousel card date format
|
||||
|
||||
```ts
|
||||
formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}); // "May 28, 2026, 14:32"
|
||||
}
|
||||
```
|
||||
|
||||
### Double-slider relative-time labels
|
||||
|
||||
`page.component.ts` formats with `Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })`. Buckets: `<45s` second, `<45min` minute, `<22h` hour, `<26d` day, `<320d` month, else year. Labels are **deduped** by string (multiple distinct timestamps that round to the same "5 hr ago" appear once).
|
||||
|
||||
When `values.length` grows (new block added), the slider snaps the **higher** of `oneValue`/`otherValue` to `MAX - 1` so the newest entry is always visible; the lower thumb (the user's left edge) stays put.
|
||||
|
||||
### Color picker (shared/color-picker)
|
||||
|
||||
- Row of 12 preset color swatches + a rainbow hue slider + a big preview swatch
|
||||
- Saturation and lightness are FIXED at 0.7 / 0.55. Only hue varies
|
||||
- Preset hues `[0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355]` — skips the green/yellow zone (60°–180°) which muddies with the rose accent palette
|
||||
|
||||
### Tickbox (tasks/tasks.component)
|
||||
|
||||
- `✓` glyph is hidden at rest (`opacity: 0`) and only revealed on interaction: `0.85` (hover/focus-visible), `1` (active). It fades via the `opacity` transition
|
||||
- The tickbox is a `<button>`, so the global animated-underline bar from `forms.scss` (`button:after { content:''; height: 2px; width: 0→100% on hover; background-color: $text-color }`) applies to it. `all: unset` strips the button's own styling but does NOT reach the pseudo-element — so `.tickbox::after` MUST re-assert `width: 100%; height: 100%; background: none`, otherwise on hover a dark `$text-color` bar paints across the top AND the box collapses to 2px (pinned at `top:0`), which centers the glyph near the top
|
||||
- `font: bold 18px/1 $normal-font` is re-asserted on `::after` because `all: unset` drops the font to serif (Times New Roman)
|
||||
- `transform: translateY(1px)` nudges the `✓` to optical centre (it sits a touch high in its em-box); `:active` must re-state the translateY or the glyph jumps when pressed
|
||||
- `.all-task` is `overflow: hidden` (NOT a scroller) and animates to `#all.scrollHeight`; tall lists scroll in the outer `.container` (`overflow-y: auto; max-height: 30vh`). Making `.all-task` itself `overflow-y: auto` pops a scrollbar the moment the tickbox `scale(1.05)`s on hover (transforms widen the scrollable-overflow box)
|
||||
|
||||
### Mobile responsive
|
||||
|
||||
- `$mobile-width: 520px` is the single breakpoint
|
||||
- Tower row: on mobile, `width: calc(66vw - var(--medium-padding)) !important` per tower with `overflow-x: auto` + `scroll-snap-type: x mandatory`. About 1.5 columns visible by default
|
||||
- Carousel cards: `width: 85vw`, placeholders `7.5vw`, carousel `padding: 0 7.5vw` → snap-center lines up perfectly
|
||||
- Modal cards (settings/tower-settings/welcome/confirm-delete): `width: 88vw; padding: var(--medium-padding)` on mobile. Confirm-buttons stack vertically with full width
|
||||
- Block hover effect (`gravitate`) gated behind `@media (hover: hover) and (pointer: fine)` so touch devices don't get stuck scale-up
|
||||
- Viewport meta blocks pinch-zoom: `<meta name="viewport" content="..., maximum-scale=1.0, user-scalable=no" />`
|
||||
- `scrollbar-gutter: stable` + `overflow-y: scroll` on `html` so modal opens don't shift content sideways
|
||||
|
||||
### Tower drag-drop
|
||||
|
||||
- CDK drag-drop on `.towers cdkDropList[orientation=horizontal]`, each tower is `cdkDrag`
|
||||
- Trash zone: `<img class="trash">` is OUTSIDE `.towers` (anchored to `page.component :host` at `bottom: 8px; left: 50%`). The legacy structure — moving it inside `.towers` breaks because it becomes a flex item
|
||||
- Trash-highlight: `pointerenter` on trash → direct DOM `document.querySelector('.cdk-drag-preview').classList.add('trash-highlight')`. Matches legacy approach
|
||||
- Drop over trash → opens a confirm modal (no immediate delete)
|
||||
|
||||
### Welcome modal + example data
|
||||
|
||||
- Shows when `!store.loading() && store.pages().length === 0`. Auto-dismisses when `pages().length > 0`
|
||||
- "Try an example" calls `store.loadExample()` which creates a "Hobbies" page with three towers (Reading, Side projects, Exercise) at varied `created_at` ages so the slider has interesting labels
|
||||
|
||||
## Frontend sharp edges
|
||||
|
||||
- **`crypto.randomUUID()` requires a secure context** (HTTPS or localhost). On a plain-HTTP origin behind nginx, it throws and `init()` rejects, leaving `loading = true` forever. Always fall back to `crypto.getRandomValues` (`uuidV4()` helper in `store.service.ts`)
|
||||
- **Angular 17+ deprecated `fileReplacements`** for env-per-build. Don't use `environment.ts` — use relative API paths everywhere + `proxy.conf.json` for ng serve
|
||||
- **Angular 19+ `application` builder copies `public/`, NOT `src/assets/`**. SVG icons must live at `frontend/public/assets/` to be served at `/assets/foo.svg`. Fonts referenced via `url()` in styles.scss go through the CSS asset pipeline and emit to `/media/`
|
||||
- **Backticks inside `styles:` template literal** close the string early — break TS parsing
|
||||
- **Async iframe-like spawning of agents in this sandbox** (host port forwarding) doesn't work — run e2e via Playwright Docker image on the same `life-towers_default` network
|
||||
|
||||
## Backend sharp edges
|
||||
|
||||
- The 256 KiB payload cap is enforced by middleware reading `Content-Length`. Chunked encoding bypasses the check. Defense-in-depth would also stream `request.stream()`
|
||||
- 10 MiB per-user quota is checked against the request body, NOT the existing user's stored total. With the 256 KiB request cap, the 10 MiB check is effectively unreachable. Documented spec gap
|
||||
- The migrations directory was moved to `src/life_towers/migrations/` (inside the package) to ship as `importlib.resources` data — don't recreate `backend/migrations/`
|
||||
|
||||
## Visual e2e
|
||||
|
||||
- `frontend/e2e/visuals.spec.ts` is the source-of-truth for "what should this look like." It captures ~15 screenshots into `frontend/visuals/` (gitignored)
|
||||
- Add a screenshot every time we land a visual change. Don't merge if it broke the visuals run
|
||||
- Mobile screenshots use a separate test that spawns its own `browser.newContext({ viewport: { width: 390, height: 844 } })` — that's the iPhone 14 Pro viewport
|
||||
|
||||
## Conventions to enforce on changes
|
||||
|
||||
- Never use the legacy `frontend-legacy/` or `backend-legacy/` paths — those folders are GONE. `_legacy_reference/` is the reference, gitignored
|
||||
- Never refactor `library/*.scss` — those files are dropped verbatim from the legacy and downstream components depend on their exports
|
||||
- Never change the API contract in `docs/api-spec.md` without updating both pydantic models, api.py, frontend `models/index.ts`, and the backend tests in `tests/test_api.py`
|
||||
- Never put modal-related elements as direct flex children of a layout container without `:host { display: contents }` on the modal — they'll add invisible flex slots
|
||||
- Always run `npm run build` after a frontend change to catch TS/template errors that don't surface in the IDE
|
||||
- Always run the visuals test after a visual change. Pull the screenshots, look at them, compare to the legacy
|
||||
|
||||
## Where to look next
|
||||
|
||||
- For the API contract: `docs/api-spec.md`
|
||||
- For visual design questions: `docs/DESIGN.md` + `_legacy_reference/frontend/src/library/`
|
||||
- For the sync flow: `frontend/src/app/services/store.service.ts` (the `init()`, `flush()`, `scheduleSave()` chain)
|
||||
- For the falling animation: `frontend/src/app/components/tower/tower.component.ts:reconcile()`
|
||||
- For drag-drop + trash: `frontend/src/app/components/page/page.component.{ts,html,scss}`
|
||||
- For deploy: `Dockerfile`, `docker-compose.yml`, `.forgejo/workflows/`
|
||||
56
Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Stage 1: SPA build
|
||||
FROM node:22-alpine AS spa-build
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Angular's application builder outputs to dist/frontend/browser/
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Runtime essentials:
|
||||
# curl — used by HEALTHCHECK
|
||||
# sqlite3 — used for `docker compose exec ... sqlite3` backups
|
||||
# uv — Python dependency installer
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir uv
|
||||
|
||||
# Create non-root user with a real shell so `docker exec ... sh` works for ops.
|
||||
RUN useradd -r -u 1000 -m -s /bin/sh appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install backend deps WITHOUT building the project (source not copied yet).
|
||||
# The package itself is resolvable via PYTHONPATH=/app/src below; this avoids
|
||||
# busting the dep-layer cache on every source change.
|
||||
COPY backend/pyproject.toml backend/uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Backend source (migrations now ship as package data under src/life_towers/)
|
||||
COPY backend/src/ ./src/
|
||||
|
||||
# SPA static files
|
||||
COPY --from=spa-build /build/dist/frontend/browser/ /app/static/
|
||||
|
||||
# Persistent data directory + recursive ownership for everything appuser
|
||||
# might read or write at runtime.
|
||||
RUN mkdir -p /data && chown -R appuser:appuser /data /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
||||
LIFE_TOWERS_STATIC_DIR=/app/static \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/src \
|
||||
PATH=/app/.venv/bin:$PATH
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "life_towers.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
98
README.md
Executable file → Normal file
|
|
@ -1,27 +1,97 @@
|
|||
# Frontend
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.svg" alt="Life Towers" width="420">
|
||||
</p>
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.3.8.
|
||||
A personal productivity tool for organising tasks into visual "towers" of blocks, grouped on pages. Each user is identified by a client-generated token — no accounts, no passwords.
|
||||
|
||||
## Development server
|
||||
## Architecture
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
The application runs as a single Docker container. A FastAPI process serves both the Angular SPA (as static files) and the JSON API on port 8000. Data is stored in a SQLite database file persisted via a volume mount at `/data`. There is no separate database server required.
|
||||
|
||||
## Code scaffolding
|
||||
The container is designed to run behind a reverse proxy (nginx, Caddy, Traefik). It honors `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` so rate limiting and logging see the real client IP, and links built with `request.url` produce the correct scheme.
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
## Quick start
|
||||
|
||||
## Build
|
||||
```bash
|
||||
# Local build (uses docker-compose.dev.yml which builds from source and
|
||||
# wipes data on `down -v`):
|
||||
docker compose -f docker-compose.dev.yml up --build -d
|
||||
```
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
|
||||
Then visit http://localhost:8000.
|
||||
|
||||
## Running unit tests
|
||||
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
```bash
|
||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
## Environment variables
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LIFE_TOWERS_IMAGE` | `life-towers:local` | The image `docker-compose.yml` will run. Point at your registry tag for production deploys. |
|
||||
| `LIFE_TOWERS_PORT` | `8000` | Host port mapped to the container. |
|
||||
| `LIFE_TOWERS_PULL_POLICY` | `missing` | `pull_policy` passed to compose. Set `always` to force-pull on `up`. |
|
||||
| `LIFE_TOWERS_ALLOWED_ORIGIN` | _(empty)_ | If set, restricts CORS to this origin. Leave empty for same-origin mode (the typical setup behind nginx). |
|
||||
| `LIFE_TOWERS_PUBLIC_URL` | _(derived from request)_ | Absolute public URL used for canonical and Open Graph image tags. Set this when serving behind a path prefix or proxy that rewrites the visible URL. |
|
||||
| `LIFE_TOWERS_FORWARDED_ALLOW_IPS` | `*` | (Optional, advanced.) Override uvicorn's `--forwarded-allow-ips` if you want to narrow the set of trusted proxies. |
|
||||
|
||||
## Further help
|
||||
## Data persistence
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
||||
SQLite data is stored at `/data/life-towers.db` inside the container, persisted via the `life-towers-data` Docker named volume. Back up with:
|
||||
|
||||
```bash
|
||||
docker compose exec life-towers sqlite3 /data/life-towers.db .dump > backup.sql
|
||||
```
|
||||
|
||||
To switch to a host bind mount for easier file-level backups, see the commented line in `docker-compose.yml`.
|
||||
|
||||
## Behind nginx
|
||||
|
||||
Sample reverse-proxy snippet:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name towers.example.com;
|
||||
|
||||
# SSL config omitted
|
||||
|
||||
client_max_body_size 4m; # backend enforces 2 MiB; give a small margin
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development without Docker
|
||||
|
||||
- Backend: `cd backend && uv run uvicorn life_towers.main:app --reload`
|
||||
- Frontend: `cd frontend && npm start` — runs `ng serve` on :4200 with `/api/*` proxied to the backend on :8000 (see `frontend/proxy.conf.json`).
|
||||
|
||||
## End-to-end tests
|
||||
|
||||
A `docker-compose.dev.yml` builds a single-container stack with an ephemeral volume — ideal for Playwright runs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build -d
|
||||
cd frontend && npm run test:e2e # http://localhost:8000
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
`PLAYWRIGHT_BASE_URL` overrides the target (e.g. `http://life-towers:8000` when Playwright itself runs in a container on the same docker network).
|
||||
|
||||
## Deployment
|
||||
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) builds and tests the backend, frontend, and Docker image on every push to `master`. On successful push to `master` it also tags and pushes the image to a registry — configure `REGISTRY_URL`, `REGISTRY_USER`, and `REGISTRY_PASSWORD` as repository secrets.
|
||||
|
||||
The deploy workflow (`.forgejo/workflows/deploy.yml`) triggers on `workflow_dispatch` or a `v*` tag push. It SSHs into the target server and runs `docker compose pull && docker compose up -d`, then polls the healthcheck. The server must have a `.env` file alongside `docker-compose.yml` that pins `LIFE_TOWERS_IMAGE` to the registry tag pushed by CI — otherwise `docker compose pull` is a no-op against the placeholder `life-towers:local`. Configure `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and `DEPLOY_PATH` as repository secrets before use.
|
||||
|
|
|
|||
86
angular.json
|
|
@ -1,86 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": [],
|
||||
"es5BrowserSupport": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "20mb",
|
||||
"maximumError": "50mb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "frontend:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "frontend:build:production"
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "frontend:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
|
||||
"exclude": ["**/node_modules/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "frontend"
|
||||
}
|
||||
40
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[project]
|
||||
name = "life-towers"
|
||||
version = "0.1.0"
|
||||
description = "Life Towers FastAPI backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
"slowapi>=0.1.9",
|
||||
"structlog>=24.1.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/life_towers"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/life_towers/migrations" = "life_towers/migrations"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
1
backend/src/life_towers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Life Towers FastAPI backend
|
||||
246
backend/src/life_towers/api.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""APIRouter with all Life Towers endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from .auth import get_current_user
|
||||
from .db import db_connection
|
||||
from .limits import limiter
|
||||
from .models import (
|
||||
BlockOut,
|
||||
DataIn,
|
||||
DataOut,
|
||||
HealthResponse,
|
||||
HslColor,
|
||||
PageOut,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
TowerOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
@limiter.limit("30/hour", key_func=lambda request: request.client.host if request.client else "unknown")
|
||||
async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
||||
token = body.token
|
||||
now = int(time.time())
|
||||
with db_connection() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?", (token,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, created_at, last_seen_at) VALUES (?, ?, ?)",
|
||||
(token, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE users SET last_seen_at = ? WHERE id = ?",
|
||||
(now, token),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("user_registered", user_id=token, new=existing is None)
|
||||
return RegisterResponse(user_id=token)
|
||||
|
||||
|
||||
@router.get("/data", response_model=DataOut)
|
||||
@limiter.limit("60/minute")
|
||||
async def get_data(
|
||||
request: Request,
|
||||
user_id: Annotated[str, Depends(get_current_user)],
|
||||
) -> DataOut:
|
||||
with db_connection() as conn:
|
||||
pages_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to
|
||||
FROM pages
|
||||
WHERE user_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
pages_out: list[PageOut] = []
|
||||
for page_row in pages_rows:
|
||||
page_id = page_row["id"]
|
||||
|
||||
tower_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, name, base_color_h, base_color_s, base_color_l
|
||||
FROM towers
|
||||
WHERE page_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
towers_out: list[TowerOut] = []
|
||||
for tower_row in tower_rows:
|
||||
tower_id = tower_row["id"]
|
||||
|
||||
block_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, tag, description, is_done, difficulty, created_at
|
||||
FROM blocks
|
||||
WHERE tower_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(tower_id,),
|
||||
).fetchall()
|
||||
|
||||
blocks_out = [
|
||||
BlockOut(
|
||||
id=b["id"],
|
||||
tag=b["tag"],
|
||||
description=b["description"],
|
||||
is_done=bool(b["is_done"]),
|
||||
difficulty=b["difficulty"],
|
||||
created_at=b["created_at"],
|
||||
)
|
||||
for b in block_rows
|
||||
]
|
||||
|
||||
towers_out.append(
|
||||
TowerOut(
|
||||
id=tower_row["id"],
|
||||
name=tower_row["name"],
|
||||
base_color=HslColor(
|
||||
h=tower_row["base_color_h"],
|
||||
s=tower_row["base_color_s"],
|
||||
l=tower_row["base_color_l"],
|
||||
),
|
||||
blocks=blocks_out,
|
||||
)
|
||||
)
|
||||
|
||||
pages_out.append(
|
||||
PageOut(
|
||||
id=page_row["id"],
|
||||
name=page_row["name"],
|
||||
hide_create_tower_button=bool(
|
||||
page_row["hide_create_tower_button"]
|
||||
),
|
||||
keep_tasks_open=bool(page_row["keep_tasks_open"]),
|
||||
default_date_from=page_row["default_date_from"],
|
||||
default_date_to=page_row["default_date_to"],
|
||||
towers=towers_out,
|
||||
)
|
||||
)
|
||||
|
||||
return DataOut(pages=pages_out)
|
||||
|
||||
|
||||
@router.put("/data", status_code=204)
|
||||
@limiter.limit("30/minute")
|
||||
async def put_data(
|
||||
request: Request,
|
||||
body: DataIn,
|
||||
user_id: Annotated[str, Depends(get_current_user)],
|
||||
) -> None:
|
||||
# Tree-replace semantics mean the request size IS the user's total storage,
|
||||
# so the 2 MiB request cap (enforced by payload_size_middleware) is the only
|
||||
# quota we need.
|
||||
now = int(time.time())
|
||||
|
||||
with db_connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Delete existing data for this user (cascades to towers + blocks)
|
||||
conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,))
|
||||
|
||||
for page_pos, page in enumerate(body.pages):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pages
|
||||
(id, user_id, position, name, hide_create_tower_button,
|
||||
keep_tasks_open, default_date_from, default_date_to,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
page.id,
|
||||
user_id,
|
||||
page_pos,
|
||||
page.name,
|
||||
1 if page.hide_create_tower_button else 0,
|
||||
1 if page.keep_tasks_open else 0,
|
||||
page.default_date_from,
|
||||
page.default_date_to,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for tower_pos, tower in enumerate(page.towers):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO towers
|
||||
(id, page_id, user_id, position, name,
|
||||
base_color_h, base_color_s, base_color_l,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
tower.id,
|
||||
page.id,
|
||||
user_id,
|
||||
tower_pos,
|
||||
tower.name,
|
||||
tower.base_color.h,
|
||||
tower.base_color.s,
|
||||
tower.base_color.l,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for block_pos, block in enumerate(tower.blocks):
|
||||
created_at = block.created_at if block.created_at is not None else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO blocks
|
||||
(id, tower_id, user_id, position, tag,
|
||||
description, is_done, difficulty,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
block.id,
|
||||
tower.id,
|
||||
user_id,
|
||||
block_pos,
|
||||
block.tag,
|
||||
block.description,
|
||||
1 if block.is_done else 0,
|
||||
block.difficulty,
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
# Update last_seen_at
|
||||
conn.execute(
|
||||
"UPDATE users SET last_seen_at = ? WHERE id = ?",
|
||||
(now, user_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
||||
53
backend/src/life_towers/auth.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Bearer token extraction, UUIDv4 validation, and DB lookup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import db_connection
|
||||
|
||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||
# must not distinguish between missing / malformed / unknown tokens — that
|
||||
# would let an attacker enumerate registered tokens.
|
||||
_UNAUTHORIZED_DETAIL = "Authentication required"
|
||||
|
||||
|
||||
def _unauthorized() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "unauthorized", "detail": _UNAUTHORIZED_DETAIL},
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||
"authorization"
|
||||
)
|
||||
if not auth_header:
|
||||
raise _unauthorized()
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise _unauthorized()
|
||||
|
||||
token = parts[1]
|
||||
|
||||
try:
|
||||
u = uuid.UUID(token)
|
||||
if u.version != 4:
|
||||
raise ValueError("Not v4")
|
||||
except (ValueError, AttributeError):
|
||||
raise _unauthorized()
|
||||
|
||||
with db_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?", (token,)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise _unauthorized()
|
||||
|
||||
return token
|
||||
95
backend/src/life_towers/db.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""SQLite connection factory, WAL/FK pragmas, and migration runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_DB_PATH: Path | None = None
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
global _DB_PATH
|
||||
if _DB_PATH is None:
|
||||
_DB_PATH = Path(os.environ.get("LIFE_TOWERS_DB_PATH", "/data/life-towers.db"))
|
||||
return _DB_PATH
|
||||
|
||||
|
||||
def _apply_pragmas(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Open a new connection with required pragmas applied."""
|
||||
path = get_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_apply_pragmas(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_connection() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Context manager yielding a connection that is closed on exit."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migration_files() -> list[tuple[str, str]]:
|
||||
"""Return [(filename, sql)] for every migration, in lexical order.
|
||||
|
||||
Migrations ship as package data under `life_towers/migrations/`, so they
|
||||
travel with the wheel and are resolvable regardless of cwd or how the
|
||||
package is installed (editable, wheel, or PYTHONPATH).
|
||||
"""
|
||||
pkg_files = resources.files("life_towers").joinpath("migrations")
|
||||
out: list[tuple[str, str]] = []
|
||||
for entry in sorted(pkg_files.iterdir(), key=lambda p: p.name):
|
||||
if entry.name.endswith(".sql"):
|
||||
out.append((entry.name, entry.read_text()))
|
||||
return out
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection) -> None:
|
||||
"""Apply pending SQL migrations in lexical order."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
for filename, sql in _migration_files():
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM schema_migrations WHERE filename = ?",
|
||||
(filename,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
continue
|
||||
|
||||
logger.info("applying_migration", filename=filename)
|
||||
conn.executescript(sql)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
|
||||
(filename, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("migration_applied", filename=filename)
|
||||
86
backend/src/life_towers/limits.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Payload size middleware and rate-limit setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import Request, Response
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||
|
||||
_TOO_LARGE_BODY = json.dumps(
|
||||
{
|
||||
"error": "payload_too_large",
|
||||
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _get_token_or_ip(request: Request) -> str:
|
||||
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1]
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||
|
||||
|
||||
def _too_large() -> Response:
|
||||
return Response(
|
||||
content=_TOO_LARGE_BODY,
|
||||
status_code=413,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def payload_size_middleware(request: Request, call_next) -> Response:
|
||||
"""Reject requests larger than PAYLOAD_LIMIT_BYTES.
|
||||
|
||||
Two-layer enforcement:
|
||||
1. If `Content-Length` is present, reject up front (cheap and avoids
|
||||
buffering anything).
|
||||
2. Otherwise (chunked encoding / missing header) wrap `request.receive`
|
||||
so the body stream is tallied as it arrives; abort the moment the
|
||||
running total exceeds the cap. This is defense-in-depth against
|
||||
clients that omit Content-Length.
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
length = int(content_length)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length > PAYLOAD_LIMIT_BYTES:
|
||||
return _too_large()
|
||||
# Trust the declared length; the ASGI server enforces it.
|
||||
return await call_next(request)
|
||||
|
||||
# No Content-Length: tally bytes as they arrive.
|
||||
received_total = 0
|
||||
too_large = False
|
||||
original_receive = request.receive
|
||||
|
||||
async def guarded_receive() -> dict:
|
||||
nonlocal received_total, too_large
|
||||
message = await original_receive()
|
||||
if message.get("type") == "http.request":
|
||||
body = message.get("body") or b""
|
||||
received_total += len(body)
|
||||
if received_total > PAYLOAD_LIMIT_BYTES:
|
||||
too_large = True
|
||||
# Tell downstream "no more body" — they may still try to
|
||||
# parse what's been seen, but we'll override the response.
|
||||
return {"type": "http.disconnect"}
|
||||
return message
|
||||
|
||||
request._receive = guarded_receive # type: ignore[attr-defined]
|
||||
response = await call_next(request)
|
||||
if too_large:
|
||||
return _too_large()
|
||||
return response
|
||||
63
backend/src/life_towers/logging.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""structlog JSON config and request logging middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid as _uuid_mod
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structlog for JSON output."""
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(0),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
||||
request_id = str(_uuid_mod.uuid4())
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||
|
||||
# Extract user_id from Authorization header for logging (no DB call here)
|
||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
user_id: str | None = None
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
user_id = parts[1]
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
duration_ms = round((time.monotonic() - start) * 1000, 2)
|
||||
|
||||
# request.client is rewritten by uvicorn's ProxyHeadersMiddleware
|
||||
# (--proxy-headers) to the real client IP when behind a trusted reverse proxy.
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
log = structlog.get_logger("access")
|
||||
log.info(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
user_id=user_id,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
response.headers["X-Request-Id"] = request_id
|
||||
return response
|
||||
226
backend/src/life_towers/main.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""ASGI app, lifespan, static files mount, route registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .api import router
|
||||
from .db import db_connection, run_migrations
|
||||
from .limits import limiter, payload_size_middleware
|
||||
from .logging import configure_logging, request_logging_middleware
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Map HTTP status codes to error code strings
|
||||
STATUS_CODE_MAP: dict[int, str] = {
|
||||
400: "bad_request",
|
||||
401: "unauthorized",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
405: "method_not_allowed",
|
||||
409: "conflict",
|
||||
413: "payload_too_large",
|
||||
422: "bad_request",
|
||||
429: "rate_limited",
|
||||
507: "quota_exceeded",
|
||||
500: "server_error",
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
configure_logging()
|
||||
logger.info("starting_up")
|
||||
with db_connection() as conn:
|
||||
run_migrations(conn)
|
||||
logger.info("migrations_complete")
|
||||
yield
|
||||
logger.info("shutting_down")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Life Towers",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
)
|
||||
|
||||
# Rate limiter state
|
||||
app.state.limiter = limiter
|
||||
|
||||
# CORS (only if env var set)
|
||||
allowed_origin = os.environ.get("LIFE_TOWERS_ALLOWED_ORIGIN")
|
||||
if allowed_origin:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[allowed_origin],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Payload size middleware
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=payload_size_middleware)
|
||||
|
||||
# Request logging middleware
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware)
|
||||
|
||||
# Rate limit exceeded handler
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Pydantic / FastAPI validation errors → 400 bad_request.
|
||||
# We do NOT echo the user-supplied input back in the detail string —
|
||||
# pydantic's default messages include the offending value, which would
|
||||
# reflect arbitrary attacker-controlled content. Instead we just list
|
||||
# the failing field paths.
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
fields = sorted(
|
||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
||||
)
|
||||
if fields:
|
||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||
else:
|
||||
detail_str = "Validation failed"
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "bad_request", "detail": detail_str},
|
||||
)
|
||||
|
||||
# HTTP exception handler → normalized JSON
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
if isinstance(exc.detail, dict):
|
||||
# Already shaped correctly (from auth.py etc.)
|
||||
detail = exc.detail
|
||||
else:
|
||||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||
detail = {"error": code, "detail": str(exc.detail)}
|
||||
|
||||
headers = getattr(exc, "headers", None) or {}
|
||||
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||
|
||||
# Generic 500 handler
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("unhandled_exception", exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "detail": "Internal server error"},
|
||||
)
|
||||
|
||||
# Register API router
|
||||
app.include_router(router)
|
||||
|
||||
# Static files
|
||||
static_dir = Path(os.environ.get("LIFE_TOWERS_STATIC_DIR", "/app/static"))
|
||||
if static_dir.exists() and static_dir.is_dir():
|
||||
_mount_static(app, static_dir)
|
||||
else:
|
||||
logger.warning("static_dir_missing", path=str(static_dir))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
||||
"""Mount static files with SPA fallback and proper cache headers."""
|
||||
|
||||
import re
|
||||
|
||||
# Matches Angular's content-hashed asset filenames, e.g.
|
||||
# main-L5B7PG5E.js, chunk-deadbeef.css, styles-6JWNHNC2.css, font.AbCdEf12.woff2
|
||||
# The hash is at least 8 chars of alphanumerics, separated from the base name
|
||||
# by either '-' (modern Angular) or '.' (older Angular / generic).
|
||||
HASHED_PATTERN = re.compile(
|
||||
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
|
||||
)
|
||||
|
||||
def _absolute_meta_urls(request: Request) -> tuple[str, str]:
|
||||
configured_public_url = os.environ.get("LIFE_TOWERS_PUBLIC_URL", "").strip()
|
||||
if configured_public_url:
|
||||
public_root = configured_public_url.rstrip("/") + "/"
|
||||
return public_root, f"{public_root}og-image.png"
|
||||
|
||||
parts = urlsplit(str(request.url))
|
||||
canonical_url = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", "", ""))
|
||||
|
||||
root_path = str(request.scope.get("root_path") or "").strip("/")
|
||||
og_image_path = f"/{root_path}/og-image.png" if root_path else "/og-image.png"
|
||||
og_image_url = urlunsplit((parts.scheme, parts.netloc, og_image_path, "", ""))
|
||||
return canonical_url, og_image_url
|
||||
|
||||
def _serve_index(file_path: Path, request: Request) -> HTMLResponse:
|
||||
canonical_url, og_image_url = _absolute_meta_urls(request)
|
||||
html = file_path.read_text(encoding="utf-8")
|
||||
html = html.replace(
|
||||
'href="/" data-dynamic-url="canonical"',
|
||||
f'href="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/" data-dynamic-url="canonical"',
|
||||
f'content="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/og-image.png" data-dynamic-url="og-image"',
|
||||
f'content="{escape(og_image_url, quote=True)}" data-dynamic-url="og-image"',
|
||||
)
|
||||
|
||||
resp = HTMLResponse(html)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
def _serve_file(file_path: Path, request: Request) -> Response:
|
||||
if file_path.name == "index.html":
|
||||
return _serve_index(file_path, request)
|
||||
|
||||
resp = FileResponse(str(file_path))
|
||||
if HASHED_PATTERN.search(file_path.name):
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(request: Request, full_path: str) -> Response:
|
||||
# API routes are handled by the API router (registered before this);
|
||||
# if execution reaches here for an /api/* path, it really is unknown.
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Real file? Serve it (with cache headers based on hash detection).
|
||||
# Guard against path traversal by resolving and re-checking containment.
|
||||
candidate = (static_dir / full_path).resolve()
|
||||
try:
|
||||
candidate.relative_to(static_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if candidate.is_file():
|
||||
return _serve_file(candidate, request)
|
||||
|
||||
# SPA fallback to index.html.
|
||||
index = static_dir / "index.html"
|
||||
if index.is_file():
|
||||
return _serve_file(index, request)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
app = create_app()
|
||||
56
backend/src/life_towers/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
-- Life Towers v4 initial schema.
|
||||
-- SQLite with WAL mode and foreign keys enabled at connection time.
|
||||
-- All timestamps are unix epoch seconds (INTEGER).
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)),
|
||||
keep_tasks_open INTEGER NOT NULL DEFAULT 0 CHECK (keep_tasks_open IN (0, 1)),
|
||||
default_date_from INTEGER,
|
||||
default_date_to INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_user_position ON pages(user_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS towers (
|
||||
id TEXT PRIMARY KEY,
|
||||
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
base_color_h REAL NOT NULL CHECK (base_color_h BETWEEN 0 AND 1),
|
||||
base_color_s REAL NOT NULL CHECK (base_color_s BETWEEN 0 AND 1),
|
||||
base_color_l REAL NOT NULL CHECK (base_color_l BETWEEN 0 AND 1),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_towers_page_position ON towers(page_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_towers_user ON towers(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
tower_id TEXT NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)),
|
||||
difficulty INTEGER NOT NULL DEFAULT 1 CHECK (difficulty >= 1),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_tower_position ON blocks(tower_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_user ON blocks(user_id);
|
||||
152
backend/src/life_towers/models.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Pydantic v2 models matching the API spec exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
import uuid as _uuid_mod
|
||||
|
||||
|
||||
def _is_uuidv4(value: str) -> bool:
|
||||
try:
|
||||
u = _uuid_mod.UUID(value)
|
||||
return u.version == 4
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
class HslColor(BaseModel):
|
||||
h: float = Field(ge=0.0, le=1.0)
|
||||
s: float = Field(ge=0.0, le=1.0)
|
||||
l: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class BlockIn(BaseModel):
|
||||
id: str
|
||||
tag: str = Field(max_length=200)
|
||||
description: str = Field(max_length=10_000)
|
||||
is_done: bool
|
||||
difficulty: int = Field(default=1, ge=1, le=100)
|
||||
created_at: Optional[int] = None
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
|
||||
|
||||
class BlockOut(BaseModel):
|
||||
id: str
|
||||
tag: str
|
||||
description: str
|
||||
is_done: bool
|
||||
difficulty: int
|
||||
created_at: int
|
||||
|
||||
|
||||
class TowerIn(BaseModel):
|
||||
id: str
|
||||
name: str = Field(max_length=200)
|
||||
base_color: HslColor
|
||||
blocks: list[BlockIn] = Field(max_length=1000)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
|
||||
|
||||
class TowerOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
base_color: HslColor
|
||||
blocks: list[BlockOut]
|
||||
|
||||
|
||||
class PageIn(BaseModel):
|
||||
id: str
|
||||
name: str = Field(max_length=200)
|
||||
hide_create_tower_button: bool = False
|
||||
keep_tasks_open: bool = False
|
||||
default_date_from: Optional[int] = None
|
||||
default_date_to: Optional[int] = None
|
||||
towers: list[TowerIn] = Field(max_length=100)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
|
||||
|
||||
class PageOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
hide_create_tower_button: bool
|
||||
keep_tasks_open: bool
|
||||
default_date_from: Optional[int]
|
||||
default_date_to: Optional[int]
|
||||
towers: list[TowerOut]
|
||||
|
||||
|
||||
class DataIn(BaseModel):
|
||||
pages: list[PageIn] = Field(max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_unique_ids(self) -> "DataIn":
|
||||
page_ids: set[str] = set()
|
||||
tower_ids: set[str] = set()
|
||||
block_ids: set[str] = set()
|
||||
total_blocks = 0
|
||||
|
||||
for page in self.pages:
|
||||
if page.id in page_ids:
|
||||
raise ValueError(f"Duplicate page id: {page.id}")
|
||||
page_ids.add(page.id)
|
||||
|
||||
for tower in page.towers:
|
||||
if tower.id in tower_ids:
|
||||
raise ValueError(f"Duplicate tower id: {tower.id}")
|
||||
tower_ids.add(tower.id)
|
||||
|
||||
for block in tower.blocks:
|
||||
if block.id in block_ids:
|
||||
raise ValueError(f"Duplicate block id: {block.id}")
|
||||
block_ids.add(block.id)
|
||||
total_blocks += 1
|
||||
|
||||
if total_blocks > 50_000:
|
||||
raise ValueError(
|
||||
f"Total blocks ({total_blocks}) exceeds maximum of 50,000"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class DataOut(BaseModel):
|
||||
pages: list[PageOut]
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError("token must be a UUIDv4")
|
||||
return v
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
1
backend/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Life Towers tests
|
||||
425
backend/tests/test_api.py
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
"""pytest + httpx AsyncClient tests for the Life Towers API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import life_towers.db as db_module
|
||||
from life_towers.db import run_migrations, get_connection
|
||||
from life_towers.limits import limiter
|
||||
from life_towers.main import create_app
|
||||
|
||||
|
||||
def make_uuidv4() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create a test client backed by a fresh SQLite database."""
|
||||
db_path = tmp_path / "test.db"
|
||||
db_module._DB_PATH = db_path
|
||||
|
||||
# Reset rate limiter state so each test starts clean
|
||||
limiter.reset()
|
||||
|
||||
# Run migrations using a fresh connection
|
||||
conn = get_connection()
|
||||
run_migrations(conn)
|
||||
conn.close()
|
||||
|
||||
app = create_app()
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
# Reset DB path for next test
|
||||
db_module._DB_PATH = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "index.html").write_text(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
</head>
|
||||
</html>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(static_dir / "og-image.png").write_bytes(b"fake png")
|
||||
monkeypatch.setenv("LIFE_TOWERS_STATIC_DIR", str(static_dir))
|
||||
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://towers.example",
|
||||
) as c:
|
||||
resp = await c.get("/tasks?utm_source=test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:url" content="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta name="twitter:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
monkeypatch.setenv("LIFE_TOWERS_PUBLIC_URL", "https://public.example/towers")
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://internal.example",
|
||||
) as c:
|
||||
resp = await c.get("/")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://public.example/towers/" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://public.example/towers/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_new_token(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
resp = await client.post("/api/v1/register", json={"token": token})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"user_id": token}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_idempotent(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
r1 = await client.post("/api/v1/register", json={"token": token})
|
||||
r2 = await client.post("/api/v1/register", json={"token": token})
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r1.json() == {"user_id": token}
|
||||
assert r2.json() == {"user_id": token}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_non_uuid_token(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/register", json={"token": "not-a-uuid"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
|
||||
# UUIDv1
|
||||
v1 = str(uuid.uuid1())
|
||||
resp = await client.post("/api/v1/register", json={"token": v1})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth / GET /data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_data_no_token(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/data")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_data_bogus_token(client: AsyncClient) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/data",
|
||||
headers={"Authorization": f"Bearer {make_uuidv4()}"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_data_empty_user(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
resp = await client.get(
|
||||
"/api/v1/data",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"pages": []}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip: register → PUT → GET
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_tree() -> dict:
|
||||
"""Build a valid tree with 2 pages × 2 towers × 3 blocks."""
|
||||
pages = []
|
||||
for pi in range(2):
|
||||
towers = []
|
||||
for ti in range(2):
|
||||
blocks = []
|
||||
for bi in range(3):
|
||||
blocks.append(
|
||||
{
|
||||
"id": make_uuidv4(),
|
||||
"tag": f"tag-{pi}-{ti}-{bi}",
|
||||
"description": f"desc-{pi}-{ti}-{bi}",
|
||||
"is_done": False,
|
||||
"difficulty": bi + 1,
|
||||
"created_at": 1700000000 + bi,
|
||||
}
|
||||
)
|
||||
towers.append(
|
||||
{
|
||||
"id": make_uuidv4(),
|
||||
"name": f"Tower {pi}-{ti}",
|
||||
"base_color": {"h": 0.1 * ti, "s": 0.5, "l": 0.6},
|
||||
"blocks": blocks,
|
||||
}
|
||||
)
|
||||
pages.append(
|
||||
{
|
||||
"id": make_uuidv4(),
|
||||
"name": f"Page {pi}",
|
||||
"hide_create_tower_button": False,
|
||||
"keep_tasks_open": False,
|
||||
"default_date_from": None,
|
||||
"default_date_to": None,
|
||||
"towers": towers,
|
||||
}
|
||||
)
|
||||
return {"pages": pages}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_trip(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
tree = _make_tree()
|
||||
put_resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert put_resp.status_code == 204
|
||||
|
||||
get_resp = await client.get("/api/v1/data", headers=headers)
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
|
||||
assert len(data["pages"]) == 2
|
||||
for pi, page in enumerate(data["pages"]):
|
||||
assert page["id"] == tree["pages"][pi]["id"]
|
||||
assert page["name"] == tree["pages"][pi]["name"]
|
||||
assert len(page["towers"]) == 2
|
||||
for ti, tower in enumerate(page["towers"]):
|
||||
assert tower["id"] == tree["pages"][pi]["towers"][ti]["id"]
|
||||
assert tower["name"] == tree["pages"][pi]["towers"][ti]["name"]
|
||||
assert len(tower["blocks"]) == 3
|
||||
for bi, block in enumerate(tower["blocks"]):
|
||||
assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"]
|
||||
assert block["tag"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["tag"]
|
||||
assert (
|
||||
block["difficulty"]
|
||||
== tree["pages"][pi]["towers"][ti]["blocks"][bi]["difficulty"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_difficulty_defaults_to_one_when_omitted(client: AsyncClient) -> None:
|
||||
"""A block sent without `difficulty` is stored and returned as 1."""
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
tree = _make_tree()
|
||||
# Drop difficulty from the very first block.
|
||||
del tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"]
|
||||
|
||||
put_resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert put_resp.status_code == 204
|
||||
|
||||
data = (await client.get("/api/v1/data", headers=headers)).json()
|
||||
assert data["pages"][0]["towers"][0]["blocks"][0]["difficulty"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_difficulty_must_be_positive(client: AsyncClient) -> None:
|
||||
"""difficulty < 1 is rejected by validation."""
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
tree = _make_tree()
|
||||
tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"] = 0
|
||||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_duplicate_page_id(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
dup_id = make_uuidv4()
|
||||
tree = _make_tree()
|
||||
tree["pages"][0]["id"] = dup_id
|
||||
tree["pages"][1]["id"] = dup_id # duplicate
|
||||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_name_too_long(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
tree = _make_tree()
|
||||
tree["pages"][0]["name"] = "A" * 201 # exceeds 200 char limit
|
||||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_body_too_large_via_content_length(client: AsyncClient) -> None:
|
||||
"""A body exceeding the 2 MiB cap, with Content-Length set, returns 413."""
|
||||
from life_towers.limits import PAYLOAD_LIMIT_BYTES
|
||||
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
over = PAYLOAD_LIMIT_BYTES + 1
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Length": str(over),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
resp = await client.put("/api/v1/data", content=b"x" * over, headers=headers)
|
||||
assert resp.status_code == 413
|
||||
body = resp.json()
|
||||
assert body == {
|
||||
"error": "payload_too_large",
|
||||
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_detail_is_uniform(client: AsyncClient) -> None:
|
||||
"""All 401 responses must share an identical detail to prevent enumeration."""
|
||||
bodies: list[dict] = []
|
||||
# missing header
|
||||
bodies.append((await client.get("/api/v1/data")).json())
|
||||
# wrong scheme
|
||||
bodies.append(
|
||||
(
|
||||
await client.get("/api/v1/data", headers={"Authorization": "Basic foo"})
|
||||
).json()
|
||||
)
|
||||
# malformed token
|
||||
bodies.append(
|
||||
(
|
||||
await client.get(
|
||||
"/api/v1/data", headers={"Authorization": "Bearer not-a-uuid"}
|
||||
)
|
||||
).json()
|
||||
)
|
||||
# well-formed but unknown token
|
||||
bodies.append(
|
||||
(
|
||||
await client.get(
|
||||
"/api/v1/data", headers={"Authorization": f"Bearer {make_uuidv4()}"}
|
||||
)
|
||||
).json()
|
||||
)
|
||||
assert len({tuple(sorted(b.items())) for b in bodies}) == 1, bodies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_replaces_prior_data(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": token})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# First PUT: 2 pages
|
||||
tree1 = _make_tree()
|
||||
await client.put("/api/v1/data", json=tree1, headers=headers)
|
||||
|
||||
# Second PUT: 1 page
|
||||
tree2 = {"pages": [tree1["pages"][0]]}
|
||||
await client.put("/api/v1/data", json=tree2, headers=headers)
|
||||
|
||||
get_resp = await client.get("/api/v1/data", headers=headers)
|
||||
data = get_resp.json()
|
||||
assert len(data["pages"]) == 1
|
||||
assert data["pages"][0]["id"] == tree1["pages"][0]["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limit (mock / direct hit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_rate_limit(client: AsyncClient) -> None:
|
||||
"""Hit /register past the per-IP limit; the next call returns 429."""
|
||||
# Limit is 30/hour/IP. Make 31 requests; the 31st must be 429.
|
||||
responses = []
|
||||
for _ in range(31):
|
||||
resp = await client.post("/api/v1/register", json={"token": make_uuidv4()})
|
||||
responses.append(resp.status_code)
|
||||
assert responses[-1] == 429, f"Expected 429 on 31st request, got: {responses[-3:]}"
|
||||
842
backend/uv.lock
generated
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "life-towers"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "slowapi" },
|
||||
{ name = "structlog" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||
{ name = "structlog", specifier = ">=24.1.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "limits"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "packaging" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slowapi"
|
||||
version = "0.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "limits" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "25.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" },
|
||||
]
|
||||
25
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Dev / e2e compose. Uses an ephemeral anonymous volume so each `up` starts
|
||||
# with a clean SQLite database — ideal for Playwright runs that expect a
|
||||
# fresh state.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up --build -d
|
||||
# # ... run tests against http://localhost:8000 ...
|
||||
# docker compose -f docker-compose.dev.yml down -v
|
||||
|
||||
services:
|
||||
life-towers:
|
||||
build: .
|
||||
image: life-towers:dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- /data
|
||||
environment:
|
||||
LIFE_TOWERS_ALLOWED_ORIGIN: ""
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
start_period: 5s
|
||||
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
services:
|
||||
life-towers:
|
||||
# Default to a registry image so `docker compose pull && up -d` deploys
|
||||
# a new build. Override LIFE_TOWERS_IMAGE in your `.env` to pin a tag,
|
||||
# or set it to e.g. `life-towers:local` and uncomment `build: .` for
|
||||
# local builds.
|
||||
image: ${LIFE_TOWERS_IMAGE:-life-towers:local}
|
||||
# build: . # uncomment for local builds (or use docker-compose.dev.yml)
|
||||
pull_policy: ${LIFE_TOWERS_PULL_POLICY:-missing}
|
||||
ports:
|
||||
- "${LIFE_TOWERS_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Named volume inherits ownership from the image (UID 1000 / appuser).
|
||||
# To switch to a bind mount for easy host-side backups, replace with:
|
||||
# - ./data:/data
|
||||
# and first run: `mkdir -p data && sudo chown 1000:1000 data`.
|
||||
- life-towers-data:/data
|
||||
environment:
|
||||
LIFE_TOWERS_ALLOWED_ORIGIN: "${LIFE_TOWERS_ALLOWED_ORIGIN:-}"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
life-towers-data:
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"hosting": {
|
||||
"public": "dist/frontend",
|
||||
"site": "towers-schmelczer-dev",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
7
frontend/.browserslistrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
last 2 Chrome versions
|
||||
last 2 Firefox versions
|
||||
last 2 Safari versions
|
||||
last 2 Edge versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
not IE 11
|
||||
4
.editorconfig → frontend/.editorconfig
Executable file → Normal file
|
|
@ -8,6 +8,10 @@ indent_size = 2
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
50
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Playwright
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
/visuals
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
frontend/.vscode/mcp.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
frontend/.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
frontend/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
95
frontend/angular.json
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"schematicCollections": [
|
||||
"angular-eslint"
|
||||
],
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "16kB",
|
||||
"maximumError": "32kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"serviceWorker": "ngsw-config.json"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/e2e/smoke.spec.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke test: drives the legacy-styled UI end-to-end.
|
||||
*
|
||||
* docker compose -f docker-compose.dev.yml up --build -d
|
||||
* PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test
|
||||
* docker compose -f docker-compose.dev.yml down -v
|
||||
*/
|
||||
test.describe('Life Towers smoke test', () => {
|
||||
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the empty-state hint that means init() completed.
|
||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Create a page via the select-add dropdown.
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
|
||||
// The page name now appears in the dropdown top.
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies');
|
||||
|
||||
// Create a tower.
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
|
||||
// Tower's name input is rendered with the tower name as its value.
|
||||
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
|
||||
|
||||
// Create a block.
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
// The tag input is inside an lt-select-add — open it and add a tag.
|
||||
await page.locator('lt-block-edit lt-select-add .top').click();
|
||||
await page
|
||||
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
|
||||
.fill('learn');
|
||||
await page
|
||||
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
|
||||
.press('Enter');
|
||||
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
||||
'Modernise the towers app',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
|
||||
// New block is pending → appears in the tasks accordion.
|
||||
// (Tasks header shows N tasks.)
|
||||
await expect(page.locator('lt-tasks')).toContainText('1');
|
||||
|
||||
// Open the tasks accordion + click the task to edit it, then flip done.
|
||||
await page.locator('lt-tasks .container').click();
|
||||
await page.locator('lt-tasks .task-container').click();
|
||||
|
||||
// Toggle done in the block-edit modal — the right label is "Done".
|
||||
const putLanded = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
|
||||
);
|
||||
// Toggle uses verbose labels — "Goal accomplished" flips it to done.
|
||||
await page
|
||||
.locator('lt-block-edit lt-toggle span')
|
||||
.filter({ hasText: 'Goal accomplished' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await putLanded;
|
||||
|
||||
// Done block now appears as a colored square in the tower's falling stack.
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
|
||||
// Reload — everything must come back from the server.
|
||||
await page.reload();
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(page.getByDisplayValue('Side projects')).toBeVisible();
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
208
frontend/e2e/visuals.spec.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual capture: drives the UI into key states and writes screenshots
|
||||
* for human review of the legacy-styled design.
|
||||
*/
|
||||
test.describe('Life Towers visuals', () => {
|
||||
test('capture key UI states', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350); // let the welcome modal finish fade-in
|
||||
await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true });
|
||||
|
||||
// Dismiss the welcome modal with Start empty, then continue.
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.screenshot({ path: 'visuals/01b-empty-state-after-dismiss.png', fullPage: true });
|
||||
|
||||
// Open the page dropdown (without creating a page yet).
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
// Wait for the slide-down animation to finish (200ms transform + buffer).
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: 'visuals/02-page-dropdown-open.png', fullPage: true });
|
||||
|
||||
// Create the page.
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
await page.locator('body').click({ position: { x: 10, y: 400 } });
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// ── Add a tower ─────────────────────────────────────────────────────────
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="New tower"]').fill('Reading');
|
||||
await page.screenshot({ path: 'visuals/03-new-tower-modal.png', fullPage: true });
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// ── Open the block-edit carousel (empty) ────────────────────────────────
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/04-block-edit-carousel-empty.png', fullPage: true });
|
||||
|
||||
// Fill in the create card. Make this one a NOT-finished task so the
|
||||
// tasks accordion has a row to show off (with the tickbox).
|
||||
const createCard = page.locator('lt-block-edit .create-card');
|
||||
await createCard.locator('lt-select-add .top').click();
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('novel');
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
await createCard
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Finish The Brothers Karamazov');
|
||||
// Uncheck "Already done" so this becomes a pending task.
|
||||
await createCard.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// Open the tasks accordion to show the new tickbox.
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator('lt-tasks .container').click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
||||
|
||||
// Hover the tickbox: must NOT pop a scrollbar in the accordion, must NOT
|
||||
// paint the global button-underline bar across the top, and the ✓ must stay
|
||||
// centred (regression guard — see tasks.component .tickbox::after).
|
||||
await page.locator('lt-tasks .tickbox').first().hover();
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('lt-tasks .container').screenshot({
|
||||
path: 'visuals/04c-tasks-tickbox-hover.png',
|
||||
});
|
||||
|
||||
// Add a couple more blocks.
|
||||
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
const cc = page.locator('lt-block-edit .create-card');
|
||||
// Re-use existing tag "novel" — click it from the select-add list.
|
||||
await cc.locator('lt-select-add .top').click();
|
||||
await page.waitForTimeout(100);
|
||||
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
}
|
||||
|
||||
// Wait for the falling animation to finish.
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: 'visuals/05-populated-falling-blocks.png', fullPage: true });
|
||||
|
||||
// ── Open carousel WITH existing blocks ──────────────────────────────────
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/06-carousel-with-existing.png', fullPage: true });
|
||||
|
||||
// Scroll one card to the left (to focus an existing block, showing neighbour mask).
|
||||
await page.locator('lt-block-edit').evaluate((el) => {
|
||||
const c = el.querySelector('.carousel') as HTMLElement | null;
|
||||
if (c) c.scrollBy({ left: -400, behavior: 'instant' as ScrollBehavior });
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: 'visuals/07-carousel-existing-focused.png', fullPage: true });
|
||||
|
||||
// Close the carousel via the exit X.
|
||||
await page.locator('lt-block-edit .exit').first().click({ force: true });
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// ── Add a second tower so we can show drag-drop ─────────────────────────
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="New tower"]').fill('Side projects');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// ── Drag a tower over the trash (capture mid-drag) ──────────────────────
|
||||
const firstTowerHandle = page.locator('lt-tower').first();
|
||||
const trash = page.locator('img.trash');
|
||||
const tb = await firstTowerHandle.boundingBox();
|
||||
if (tb) {
|
||||
// Pick up the tower from its center, then move toward the trash.
|
||||
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
|
||||
await page.mouse.down();
|
||||
// Move in two stages — first to dislodge cdkDrag, then over the trash.
|
||||
await page.mouse.move(tb.x + tb.width / 2 + 30, tb.y + tb.height / 2 + 30, { steps: 8 });
|
||||
const trb = await trash.boundingBox();
|
||||
if (trb) {
|
||||
await page.mouse.move(trb.x + trb.width / 2, trb.y + trb.height / 2, { steps: 12 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: 'visuals/08-dragging-over-trash.png', fullPage: true });
|
||||
}
|
||||
// Actually release over the trash to trigger the confirm-delete modal.
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: 'visuals/08b-confirm-delete-modal.png', fullPage: true });
|
||||
// Cancel — don't actually delete the tower.
|
||||
await page.locator('.confirm-buttons button').filter({ hasText: 'Cancel' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
}
|
||||
|
||||
// ── Move the date-range slider thumb — blocks should ascend out ────────
|
||||
const slider = page.locator('lt-double-slider input[type="range"]').first();
|
||||
const sb = await slider.boundingBox();
|
||||
if (sb) {
|
||||
// Drag the left thumb from 0 toward the right (~80% of slider width).
|
||||
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
// Wait long enough for the 1.5s ascend transition.
|
||||
await page.waitForTimeout(1700);
|
||||
await page.screenshot({ path: 'visuals/09-date-filter-ascended.png', fullPage: true });
|
||||
// Restore the range and screenshot the descend back to rest.
|
||||
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(1700);
|
||||
await page.screenshot({ path: 'visuals/10-date-filter-restored.png', fullPage: true });
|
||||
}
|
||||
|
||||
// ── Settings modal ──────────────────────────────────────────────────────
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('"Load sample towers" populates a sample page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
await page.getByRole('button', { name: 'Load sample towers' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: 'visuals/12-example-data.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('Mobile viewport — welcome + example + carousel', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto((process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000') + '/');
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/13-mobile-welcome.png', fullPage: true });
|
||||
|
||||
await page.getByRole('button', { name: 'Load sample towers' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
|
||||
|
||||
// Open the block-edit carousel for the first tower's first task.
|
||||
await page.locator('lt-tasks .container').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('lt-tasks .task-container').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
55
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// @ts-check
|
||||
const eslint = require('@eslint/js');
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const angular = require('angular-eslint');
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
tseslint.configs.stylistic,
|
||||
angular.configs.tsRecommended,
|
||||
],
|
||||
processor: angular.processInlineTemplates,
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: ['lt', 'app'],
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: ['lt', 'app'],
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
// Allow output named 'close' - it is not a standard DOM event on custom elements
|
||||
'@angular-eslint/no-output-native': 'off',
|
||||
// Allow empty arrow functions in catch clauses for fire-and-forget patterns
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
// Relax label association check – our modal inputs are associated via id/for
|
||||
'@angular-eslint/template/label-has-associated-control': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Vitest test files – relax unused-vars for test utilities
|
||||
files: ['**/*.vitest.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
28
frontend/ngsw-config.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.csr.html",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": ["/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10611
frontend/package-lock.json
generated
Normal file
44
frontend/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.2.13",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.13",
|
||||
"@angular/cli": "^21.2.13",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"angular-eslint": "21.4.0",
|
||||
"eslint": "^10.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "8.59.2",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
30
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests target the full stack served by docker-compose.dev.yml at
|
||||
* http://localhost:8000 (FastAPI serving both the API and the built SPA).
|
||||
* Override with PLAYWRIGHT_BASE_URL to point at a different deployment.
|
||||
*
|
||||
* docker compose -f docker-compose.dev.yml up --build -d
|
||||
* npm run test:e2e
|
||||
* docker compose -f docker-compose.dev.yml down -v
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
7
frontend/proxy.conf.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
0
src/assets/arrow.svg → frontend/public/assets/arrow.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 746 B |
0
src/assets/pen.svg → frontend/public/assets/pen.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 737 B |
0
src/assets/plus-sign.svg → frontend/public/assets/plus-sign.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
0
src/assets/trash.svg → frontend/public/assets/trash.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3 KiB |
0
src/assets/x-sign.svg → frontend/public/assets/x-sign.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 816 B |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
13
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
13
frontend/public/logo-mark.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
frontend/public/logo-mono.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#5d576b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
13
frontend/public/logo.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60" fill="#5d576b"/><path d="M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#a2666f"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
59
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "Life Towers",
|
||||
"short_name": "Towers",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"theme_color": "#a2666f",
|
||||
"background_color": "#fffcf0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/og-image.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
22
frontend/src/app/app.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
isDevMode,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideHttpClient(withFetch()),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
],
|
||||
};
|
||||
343
frontend/src/app/app.html
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
0
src/app/components/modal/modals/get-started/get-started.component.scss → frontend/src/app/app.scss
Executable file → Normal file
21
frontend/src/app/app.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
|
||||
import { StoreService } from './services/store.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [PagesComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<lt-pages />`,
|
||||
})
|
||||
export class App implements OnInit {
|
||||
private readonly store = inject(StoreService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.analytics.init();
|
||||
this.store.init();
|
||||
}
|
||||
}
|
||||
62
frontend/src/app/components/block/block.component.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
/**
|
||||
* A block rendered as a small COLORED SQUARE (1/6 of tower width).
|
||||
* Only DONE blocks appear here; pending blocks appear in the tasks accordion.
|
||||
* Clicking opens the block-edit modal in the parent tower component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lt-block',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Edit completed task"
|
||||
[class.hovered]="hovered()"
|
||||
[style.background-color]="color()"
|
||||
(click)="clicked.emit()"
|
||||
(keydown.enter)="clicked.emit()"
|
||||
(keydown.space)="$event.preventDefault(); clicked.emit()"
|
||||
></div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
width: calc(100% / 6);
|
||||
padding-bottom: calc(100% / 6);
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&:hover,
|
||||
&.hovered {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlockComponent {
|
||||
readonly block = input.required<Block>();
|
||||
readonly baseColor = input.required<HslColor>();
|
||||
readonly hovered = input(false);
|
||||
|
||||
/** Emits when the square is clicked — parent opens the block-edit modal. */
|
||||
readonly clicked = output<void>();
|
||||
|
||||
readonly color = computed(() => getColorOfTag(this.block().tag, this.baseColor()));
|
||||
}
|
||||
795
frontend/src/app/components/modal/block-edit.component.ts
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
HostListener,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
export interface BlockEditSave {
|
||||
/** null = create a new block */
|
||||
id: string | null;
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
interface EditedValue {
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-block-edit',
|
||||
standalone: true,
|
||||
imports: [SelectAddComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (viewTitle()) {
|
||||
<h2 class="view-title">{{ viewTitle() }}</h2>
|
||||
}
|
||||
|
||||
<section
|
||||
#container
|
||||
class="carousel"
|
||||
(scroll)="onScroll()"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
<div class="card placeholder"></div>
|
||||
|
||||
@for (b of blocks(); track b.id; let i = $index) {
|
||||
<div
|
||||
class="card"
|
||||
[class.active]="activeIdx() === i + 1"
|
||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||
(click)="onCardClick(i + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||
></div>
|
||||
<h1>{{ formatDate(b.created_at, true) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<lt-select-add
|
||||
[items]="tags()"
|
||||
[selected]="editedFor(b.id).tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
[placeholder]="tagPlaceholder('Tag this item…')"
|
||||
(select)="updateTag(b.id, $event)"
|
||||
(add)="updateTag(b.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
[value]="editedFor(b.id).description"
|
||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||
(blur)="flushExisting(b.id)"
|
||||
></textarea>
|
||||
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="editedFor(b.id).is_done"
|
||||
(change)="updateDone(b.id, $any($event.target).checked)"
|
||||
/>
|
||||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="editedFor(b.id).difficulty <= 1"
|
||||
(click)="updateDifficulty(b.id, -1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ editedFor(b.id).difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="card create-card"
|
||||
[class.active]="activeIdx() === blocks().length + 1"
|
||||
[class.near-active]="activeIdx() === blocks().length"
|
||||
(click)="onCardClick(blocks().length + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfNewTag()"
|
||||
></div>
|
||||
<h1>Create now</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<lt-select-add
|
||||
[items]="tags()"
|
||||
[selected]="newValue().tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
[placeholder]="tagPlaceholder('Set a category…')"
|
||||
(select)="updateNewTag($event)"
|
||||
(add)="updateNewTag($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
[value]="newValue().description"
|
||||
(input)="updateNewDescription($any($event.target).value)"
|
||||
></textarea>
|
||||
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="newValue().is_done"
|
||||
(change)="updateNewDone($any($event.target).checked)"
|
||||
/>
|
||||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="newValue().difficulty <= 1"
|
||||
(click)="updateNewDifficulty(-1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ newValue().difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button
|
||||
(click)="submitNew(); $event.stopPropagation()"
|
||||
[disabled]="!newValue().tag"
|
||||
>
|
||||
Create and exit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card placeholder"></div>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
@include center-child();
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10001; // above modal backdrop (10000)
|
||||
|
||||
@media (max-height: $min-height) {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.view-title {
|
||||
position: fixed;
|
||||
top: var(--large-padding);
|
||||
left: var(--large-padding);
|
||||
right: var(--large-padding);
|
||||
z-index: 10002;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: var(--title-clearance) 0 var(--large-padding);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: var(--title-clearance) var(--medium-padding) var(--medium-padding);
|
||||
}
|
||||
|
||||
@media (max-height: $min-height) {
|
||||
min-height: max-content;
|
||||
align-items: flex-start;
|
||||
padding-top: var(--title-clearance);
|
||||
padding-bottom: var(--medium-padding);
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card();
|
||||
box-shadow: $shadow;
|
||||
display: block;
|
||||
transform-origin: center center;
|
||||
flex: 0 0 auto;
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
scroll-snap-align: center;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: min(88vw, 360px);
|
||||
max-width: calc(100vw - (2 * var(--medium-padding)));
|
||||
padding: var(--medium-padding);
|
||||
margin: 0 calc(var(--small-padding) / 2);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
margin: calc(var(--large-padding) / 2);
|
||||
position: relative;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
opacity: 0.6;
|
||||
transition: opacity $long-animation-time;
|
||||
|
||||
&.near-active {
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10000;
|
||||
@include card();
|
||||
opacity: 1;
|
||||
transition: opacity $long-animation-time;
|
||||
pointer-events: none;
|
||||
@media (max-width: $mobile-width) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .mask {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.near-active .mask {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: var(--large-padding);
|
||||
}
|
||||
|
||||
&.placeholder {
|
||||
opacity: 0 !important;
|
||||
width: 60vw;
|
||||
max-width: 60vw;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: var(--medium-padding);
|
||||
max-width: var(--medium-padding);
|
||||
min-width: var(--medium-padding);
|
||||
}
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
position: relative;
|
||||
gap: var(--small-padding);
|
||||
|
||||
h1 {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@include exit();
|
||||
}
|
||||
|
||||
.block-dot {
|
||||
@include square(12px);
|
||||
margin-right: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-add-container {
|
||||
// When the create card has no tag chosen, glow the dropdown red as a
|
||||
// gentle "required" cue — matches the legacy ghost-button affordance.
|
||||
&.required-empty lt-select-add {
|
||||
box-shadow: 0 0 0 0.75px rgba(181, 63, 63, 0.5);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.done-checkbox {
|
||||
@include medium-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--small-padding);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto var(--medium-padding);
|
||||
cursor: pointer;
|
||||
|
||||
input[type='checkbox'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@include square(22px);
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: $light-color;
|
||||
box-shadow: $shadow-border;
|
||||
cursor: pointer;
|
||||
transition: background-color $short-animation-time, box-shadow $long-animation-time, transform $short-animation-time;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 3px;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
border: solid $light-color;
|
||||
border-width: 0 2px 2px 0;
|
||||
opacity: 0;
|
||||
transform: rotate(45deg) scale(0.8);
|
||||
transition: opacity $short-animation-time, transform $short-animation-time;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: $text-color;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
@include medium-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--small-padding);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto var(--medium-padding);
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--small-padding);
|
||||
|
||||
.value {
|
||||
min-width: 1.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
button.step {
|
||||
all: unset; // strip native + global button styles
|
||||
@include square(22px);
|
||||
@include center-child();
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background: $light-color;
|
||||
box-shadow: $shadow-border;
|
||||
cursor: pointer;
|
||||
// all:unset drops the font to serif and the global button's hover
|
||||
// underline (button::after) survives the reset — re-assert both.
|
||||
font: bold 18px/1 $normal-font;
|
||||
color: $text-color;
|
||||
user-select: none;
|
||||
transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time;
|
||||
|
||||
&::after { content: none; }
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(26px);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 24px;
|
||||
}
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
lt-select-add,
|
||||
.done-checkbox {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
min-height: 42px;
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlockEditComponent implements AfterViewInit {
|
||||
readonly viewTitle = input<string>('');
|
||||
readonly blocks = input.required<Block[]>();
|
||||
readonly activeBlockId = input<string | null>(null);
|
||||
readonly tags = input<string[]>([]);
|
||||
readonly baseColor = input.required<HslColor>();
|
||||
/** Default for `is_done` on the create card. */
|
||||
readonly defaultDone = input<boolean>(true);
|
||||
|
||||
readonly save = output<BlockEditSave>();
|
||||
readonly delete = output<string>();
|
||||
readonly close = output<void>();
|
||||
|
||||
private readonly container =
|
||||
viewChild<ElementRef<HTMLElement>>('container');
|
||||
|
||||
// Per-block edited values, keyed by block ID.
|
||||
readonly editedValues = signal<Map<string, EditedValue>>(new Map());
|
||||
|
||||
// Pending new block being authored on the create card.
|
||||
readonly newValue = signal<EditedValue>({
|
||||
tag: '',
|
||||
description: '',
|
||||
is_done: true,
|
||||
difficulty: 1,
|
||||
});
|
||||
|
||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||
readonly activeIdx = signal(1);
|
||||
|
||||
private scrollToken = 0;
|
||||
|
||||
constructor() {
|
||||
// Seed editedValues from input blocks (and re-seed if input changes).
|
||||
effect(() => {
|
||||
const bs = this.blocks();
|
||||
const m = new Map<string, EditedValue>();
|
||||
for (const b of bs) {
|
||||
m.set(b.id, {
|
||||
tag: b.tag,
|
||||
description: b.description,
|
||||
is_done: b.is_done,
|
||||
difficulty: b.difficulty ?? 1,
|
||||
});
|
||||
}
|
||||
untracked(() => this.editedValues.set(m));
|
||||
});
|
||||
|
||||
// Seed the newValue tag from tags input on first run.
|
||||
effect(() => {
|
||||
const t = this.tags();
|
||||
untracked(() => {
|
||||
const cur = this.newValue();
|
||||
if (!cur.tag && t.length > 0) {
|
||||
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
|
||||
} else if (!cur.tag) {
|
||||
this.newValue.set({ ...cur, is_done: this.defaultDone() });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Position scroll on the focused card (or the create card if none).
|
||||
queueMicrotask(() => {
|
||||
const blocks = this.blocks();
|
||||
const focusId = this.activeBlockId();
|
||||
const focusIdx = focusId
|
||||
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
|
||||
: blocks.length;
|
||||
this.scrollToChild(focusIdx + 1, false);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
editedFor(id: string): EditedValue {
|
||||
return (
|
||||
this.editedValues().get(id) ?? {
|
||||
tag: '',
|
||||
description: '',
|
||||
is_done: false,
|
||||
difficulty: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
colorOfTagForBlock(id: string): string {
|
||||
const v = this.editedFor(id);
|
||||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
||||
}
|
||||
|
||||
tagPlaceholder(fallback: string): string {
|
||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||
}
|
||||
|
||||
colorOfNewTag = computed(() => {
|
||||
const t = this.newValue().tag;
|
||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
});
|
||||
|
||||
formatDate(ts: number, compact = false): string {
|
||||
const d = new Date(ts * 1000);
|
||||
if (compact) {
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
// e.g. "May 28, 2026, 14:32"
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Existing-card mutations (auto-save on each change) ─────────────────────
|
||||
|
||||
updateTag(id: string, tag: string): void {
|
||||
this.patchEdited(id, { tag });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
updateDescription(id: string, description: string): void {
|
||||
this.patchEdited(id, { description });
|
||||
// Description flush deferred to (blur) to avoid PUT per keystroke.
|
||||
}
|
||||
|
||||
updateDone(id: string, is_done: boolean): void {
|
||||
this.patchEdited(id, { is_done });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
||||
this.patchEdited(id, { difficulty: next });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
private patchEdited(id: string, patch: Partial<EditedValue>): void {
|
||||
this.editedValues.update((m) => {
|
||||
const v = m.get(id);
|
||||
if (!v) return m;
|
||||
const next = new Map(m);
|
||||
next.set(id, { ...v, ...patch });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
flushExisting(id: string): void {
|
||||
const v = this.editedFor(id);
|
||||
if (!v.tag) return; // Skip empty saves
|
||||
this.save.emit({
|
||||
id,
|
||||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(id: string): void {
|
||||
this.delete.emit(id);
|
||||
}
|
||||
|
||||
// ── Create-card mutations ──────────────────────────────────────────────────
|
||||
|
||||
updateNewTag(tag: string): void {
|
||||
this.newValue.update((v) => ({ ...v, tag }));
|
||||
}
|
||||
|
||||
updateNewDescription(description: string): void {
|
||||
this.newValue.update((v) => ({ ...v, description }));
|
||||
}
|
||||
|
||||
updateNewDone(is_done: boolean): void {
|
||||
this.newValue.update((v) => ({ ...v, is_done }));
|
||||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
const v = this.newValue();
|
||||
if (!v.tag) return;
|
||||
this.save.emit({
|
||||
id: null,
|
||||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
// ── Scroll handling ────────────────────────────────────────────────────────
|
||||
|
||||
onCardClick(idx: number): void {
|
||||
if (idx !== this.activeIdx()) {
|
||||
this.scrollToChild(idx, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the carousel when the user clicks anywhere that isn't a real card. */
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && !target.closest('.card:not(.placeholder)')) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
const token = ++this.scrollToken;
|
||||
setTimeout(() => {
|
||||
if (token === this.scrollToken) this.adjustPosition();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.scrollToChild(this.activeIdx(), false);
|
||||
}
|
||||
|
||||
private scrollToChild(idx: number, smooth: boolean): void {
|
||||
const container = this.container()?.nativeElement;
|
||||
if (!container) return;
|
||||
const card = container.children.item(idx) as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const left =
|
||||
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
|
||||
this.activeIdx.set(idx);
|
||||
}
|
||||
|
||||
private adjustPosition(): void {
|
||||
const container = this.container()?.nativeElement;
|
||||
if (!container) return;
|
||||
const center = container.scrollLeft + container.clientWidth / 2;
|
||||
let nearestIdx = 1;
|
||||
let minDist = Infinity;
|
||||
// children[0] and children[last] are the placeholders — skip.
|
||||
for (let i = 1; i < container.children.length - 1; i++) {
|
||||
const child = container.children.item(i) as HTMLElement;
|
||||
const c = child.offsetLeft + child.offsetWidth / 2;
|
||||
const d = Math.abs(c - center);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
if (nearestIdx !== this.activeIdx()) {
|
||||
this.scrollToChild(nearestIdx, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
frontend/src/app/components/modal/modal.component.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
output,
|
||||
input,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { ModalStateService } from '../../services/modal-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-modal',
|
||||
standalone: true,
|
||||
imports: [A11yModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section
|
||||
class="modal"
|
||||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
<div
|
||||
class="modal__dialog"
|
||||
#dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="labelledBy()"
|
||||
[attr.aria-describedby]="describedBy()"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
(keydown.escape)="onClose()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
/* Keep the component host out of parent flex/grid flow. Parent containers
|
||||
apply spacing to direct children, so relying on the fixed child alone can
|
||||
still let <lt-modal> become a layout item when it mounts. */
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
section.modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include center-child();
|
||||
padding: var(--large-padding);
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 248, 248, 0.94);
|
||||
transition: opacity 300ms;
|
||||
opacity: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
|
||||
@media (max-height: $min-height) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||
readonly labelledBy = input<string | null>(null);
|
||||
readonly describedBy = input<string | null>(null);
|
||||
readonly close = output<void>();
|
||||
|
||||
// The active signal starts false; AfterViewInit flips it true on next tick
|
||||
// so the 300ms opacity transition fires on entry (0 → 1).
|
||||
readonly active = signal(false);
|
||||
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
private escListener!: (e: KeyboardEvent) => void;
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.previousFocus = document.activeElement as HTMLElement;
|
||||
// Track open state so towers can be locked while any modal is mounted.
|
||||
this.modalState.open();
|
||||
// Defer one tick so the opacity transition runs (0 → 1).
|
||||
setTimeout(() => this.active.set(true), 0);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.modalState.close();
|
||||
this.previousFocus?.focus();
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
const dialog = this.dialogRef()?.nativeElement;
|
||||
if (dialog && !dialog.contains(event.target as Node)) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
151
frontend/src/app/components/modal/page-settings.component.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Page } from '../../models';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
|
||||
export interface PageSettingsResult {
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-page-settings',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, ToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
id="ps-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
placeholder="Page name…"
|
||||
/>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="hideCreateTowerButton.set($event)"
|
||||
offLabel="Show add-tower button"
|
||||
onLabel="Hide add-tower button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="keepTasksOpen.set($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ page() ? 'Save' : 'Create page' }}
|
||||
</button>
|
||||
|
||||
@if (page()) {
|
||||
<button type="button" (click)="delete.emit()">Delete page</button>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
display: block;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PageSettingsComponent implements OnInit {
|
||||
readonly page = input<Page | null>(null);
|
||||
readonly save = output<PageSettingsResult>();
|
||||
readonly delete = output<void>();
|
||||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
});
|
||||
|
||||
hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
const p = this.page();
|
||||
if (p) {
|
||||
this.form.patchValue({ name: p.name });
|
||||
this.hideCreateTowerButton.set(p.hide_create_tower_button);
|
||||
this.keepTasksOpen.set(p.keep_tasks_open);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const v = this.form.value;
|
||||
this.save.emit({
|
||||
name: v.name ?? '',
|
||||
hide_create_tower_button: this.hideCreateTowerButton(),
|
||||
keep_tasks_open: this.keepTasksOpen(),
|
||||
});
|
||||
}
|
||||
}
|
||||
312
frontend/src/app/components/modal/settings.component.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
|
||||
export interface UpdatePagePayload {
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
}
|
||||
|
||||
const UUIDV4_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-settings',
|
||||
standalone: true,
|
||||
imports: [ToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
<h2>Settings</h2>
|
||||
|
||||
@if (page()) {
|
||||
<section class="page-section">
|
||||
<h3>This page</h3>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
[value]="pageName()"
|
||||
(blur)="onRenamePage($any($event.target).value)"
|
||||
placeholder="Page name…"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
aria-label="Page name"
|
||||
/>
|
||||
|
||||
<div class="toggle-list">
|
||||
<lt-toggle
|
||||
class="setting-toggle"
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="onHideCreateTowerButtonChange($event)"
|
||||
offLabel="Show add-tower button"
|
||||
onLabel="Hide add-tower button"
|
||||
/>
|
||||
|
||||
<lt-toggle
|
||||
class="setting-toggle"
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="onKeepTasksOpenChange($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="danger" type="button" (click)="deletePage.emit()">
|
||||
Delete this page
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
}
|
||||
|
||||
<section class="account-section">
|
||||
<h3>Account</h3>
|
||||
|
||||
<p class="hint">Copy this token to another device to permanently sync your progress</p>
|
||||
<div class="token-row">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
[value]="token()"
|
||||
aria-label="Your account token"
|
||||
/>
|
||||
<button type="button" (click)="onCopy()">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Paste a token to switch accounts</p>
|
||||
<div class="token-row">
|
||||
<input
|
||||
type="text"
|
||||
[class.error]="tokenInputTouched() && tokenInput() && !isValidToken()"
|
||||
placeholder="Paste a UUID…"
|
||||
[value]="tokenInput()"
|
||||
(input)="onTokenInput($any($event.target).value)"
|
||||
(blur)="tokenInputTouched.set(true)"
|
||||
aria-label="Paste token to switch account"
|
||||
/>
|
||||
<button type="button" [disabled]="!isValidToken()" (click)="onSwitch()">
|
||||
Switch
|
||||
</button>
|
||||
</div>
|
||||
@if (tokenInputTouched() && tokenInput() && !isValidToken()) {
|
||||
<p class="error-message">Not a valid token. Must be a UUIDv4.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 480px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
text-align: left;
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--large-padding) 0;
|
||||
padding: 0 36px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--medium-padding) 0;
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
section {
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
margin-bottom: var(--large-padding);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
margin: var(--large-padding) 0;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
button:not(.exit) {
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
lt-toggle.setting-toggle {
|
||||
--toggle-label-width: 145px;
|
||||
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: var(--small-padding);
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.035);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
color: rgba($text-color, 0.7);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
gap: var(--small-padding);
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-wrap: wrap;
|
||||
input { width: 100%; }
|
||||
button { margin-left: auto; }
|
||||
}
|
||||
}
|
||||
|
||||
input.error {
|
||||
box-shadow: 0 1px #b53f3f;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #b53f3f;
|
||||
font-size: var(--small-font-size);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
|
||||
&:after {
|
||||
background-color: #b53f3f;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent {
|
||||
readonly token = input.required<string>();
|
||||
readonly page = input<Page | null>(null);
|
||||
|
||||
readonly close = output<void>();
|
||||
readonly switchAccount = output<string>();
|
||||
readonly updatePage = output<UpdatePagePayload>();
|
||||
readonly deletePage = output<void>();
|
||||
|
||||
// Page-settings state — seeded from page() input
|
||||
readonly pageName = signal('');
|
||||
readonly hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
// Token-switch state
|
||||
readonly tokenInput = signal('');
|
||||
readonly tokenInputTouched = signal(false);
|
||||
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const p = this.page();
|
||||
if (p) {
|
||||
this.pageName.set(p.name);
|
||||
this.hideCreateTowerButton.set(p.hide_create_tower_button);
|
||||
this.keepTasksOpen.set(p.keep_tasks_open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onRenamePage(value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
this.pageName.set(trimmed);
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
onHideCreateTowerButtonChange(value: boolean): void {
|
||||
this.hideCreateTowerButton.set(value);
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
onKeepTasksOpenChange(value: boolean): void {
|
||||
this.keepTasksOpen.set(value);
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
private flushPageUpdate(): void {
|
||||
this.updatePage.emit({
|
||||
name: this.pageName(),
|
||||
hide_create_tower_button: this.hideCreateTowerButton(),
|
||||
keep_tasks_open: this.keepTasksOpen(),
|
||||
});
|
||||
}
|
||||
|
||||
onCopy(): void {
|
||||
navigator.clipboard.writeText(this.token()).catch(() => {});
|
||||
}
|
||||
|
||||
onTokenInput(value: string): void {
|
||||
this.tokenInput.set(value.trim());
|
||||
}
|
||||
|
||||
onSwitch(): void {
|
||||
if (!this.isValidToken()) return;
|
||||
this.switchAccount.emit(this.tokenInput());
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
161
frontend/src/app/components/modal/tower-settings.component.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Tower, HslColor } from '../../models';
|
||||
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
|
||||
|
||||
export interface TowerSettingsResult {
|
||||
name: string;
|
||||
base_color: HslColor;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-tower-settings',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
id="ts-name"
|
||||
name="towerName"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
class="title-input"
|
||||
/>
|
||||
|
||||
<div class="picker-row">
|
||||
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
||||
</div>
|
||||
|
||||
@if (tower()) {
|
||||
<!-- Editing an existing tower: changes auto-save, so there's no Save
|
||||
button — only the destructive action remains explicit. -->
|
||||
<button type="button" (click)="delete.emit()">Delete tower</button>
|
||||
} @else {
|
||||
<button type="submit" [disabled]="form.invalid">Create tower</button>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
padding-top: calc(var(--large-padding) + var(--medium-padding));
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
display: block;
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
}
|
||||
|
||||
form {
|
||||
@include inner-spacing(var(--large-padding));
|
||||
}
|
||||
|
||||
.title-input {
|
||||
@include title-text();
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TowerSettingsComponent implements OnInit {
|
||||
readonly tower = input<Tower | null>(null);
|
||||
readonly save = output<TowerSettingsResult>();
|
||||
readonly delete = output<void>();
|
||||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
});
|
||||
|
||||
currentColor: HslColor = randomDefaultColor();
|
||||
|
||||
ngOnInit(): void {
|
||||
const t = this.tower();
|
||||
if (t) {
|
||||
this.form.patchValue({ name: t.name });
|
||||
this.currentColor = { ...t.base_color };
|
||||
|
||||
// Edit mode: persist name changes as they happen. Wire this up *after*
|
||||
// the initial patchValue so seeding the form doesn't fire a save.
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.autoSave());
|
||||
}
|
||||
}
|
||||
|
||||
onColorChange(color: HslColor): void {
|
||||
this.currentColor = color;
|
||||
// In edit mode the picker is a live control — commit each change.
|
||||
if (this.tower()) this.autoSave();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
// Only the create flow reaches here via its Submit button; edit mode
|
||||
// auto-saves (and Enter on the single field is a harmless redundant save).
|
||||
if (this.form.invalid) return;
|
||||
this.emitSave();
|
||||
}
|
||||
|
||||
/** Emit a save only when the form is valid (skips e.g. an empty name). */
|
||||
private autoSave(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.emitSave();
|
||||
}
|
||||
|
||||
private emitSave(): void {
|
||||
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
|
||||
}
|
||||
}
|
||||
|
||||
function randomDefaultColor(): HslColor {
|
||||
// Pick a hue in [0°, 30°] ∪ [200°, 360°] — warm or cool, avoid green.
|
||||
const warm = Math.random() < 0.5;
|
||||
const hueDeg = warm ? Math.random() * 30 : 200 + Math.random() * 160;
|
||||
return { h: hueDeg / 360, s: 0.7, l: 0.55 };
|
||||
}
|
||||
70
frontend/src/app/components/page/page.component.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<section
|
||||
class="towers"
|
||||
cdkDropList
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="onTowerDropped($event)"
|
||||
>
|
||||
@for (tower of page().towers; track tower.id) {
|
||||
<lt-tower
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
||||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
[animateInitialStack]="animateInitialStack()"
|
||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||
(saveBlock)="onSaveBlock(tower.id, $event)"
|
||||
(addBlock)="onAddBlock(tower.id, $event)"
|
||||
(deleteBlock)="onDeleteBlock(tower.id, $event)"
|
||||
/>
|
||||
}
|
||||
@if (!page().hide_create_tower_button) {
|
||||
<div class="add-tower-wrapper">
|
||||
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Trash zone is positioned relative to :host, not .towers — matches legacy. -->
|
||||
<img
|
||||
class="trash"
|
||||
src="assets/trash.svg"
|
||||
alt="Delete tower"
|
||||
[class.active]="isDragging()"
|
||||
(pointerenter)="onTrashEnter()"
|
||||
(pointerleave)="onTrashLeave()"
|
||||
/>
|
||||
|
||||
@if (showSlider()) {
|
||||
<div class="double-slider-container" [class.transparent]="isDragging()">
|
||||
<lt-double-slider
|
||||
[values]="blockDates()"
|
||||
[labels]="dateLabels()"
|
||||
(rangeChange)="onSliderRangeChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showAddTower()) {
|
||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (confirmDeleteTowerId(); as towerId) {
|
||||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
<div class="confirm-buttons">
|
||||
<button type="button" (click)="cancelTowerDelete()">Cancel</button>
|
||||
<button type="button" class="danger" (click)="confirmTowerDelete()">Delete tower</button>
|
||||
</div>
|
||||
</div>
|
||||
</lt-modal>
|
||||
}
|
||||
183
frontend/src/app/components/page/page.component.scss
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
@import '../../../styles';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative; // anchor for absolute-positioned .trash
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.towers {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
transition: box-shadow $short-animation-time;
|
||||
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&.cdk-drop-list-dragging {
|
||||
*:not(.cdk-drag-placeholder) {
|
||||
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.add-tower-wrapper {
|
||||
@include center-child();
|
||||
img.add-tower {
|
||||
height: 48px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
opacity: 0.33;
|
||||
transition: opacity $long-animation-time;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
|
||||
@media (max-width: $mobile-width) {
|
||||
--mobile-tower-width: calc(66vw - var(--small-padding));
|
||||
--mobile-tower-side-padding: max(
|
||||
var(--medium-padding),
|
||||
calc((100% - var(--mobile-tower-width)) / 2)
|
||||
);
|
||||
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: var(--mobile-tower-side-padding);
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--mobile-tower-side-padding);
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: var(--mobile-tower-width) !important;
|
||||
max-width: var(--mobile-tower-width) !important;
|
||||
min-width: var(--mobile-tower-width) !important;
|
||||
scroll-snap-align: center;
|
||||
flex: 0 0 var(--mobile-tower-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.double-slider-container {
|
||||
width: 100%;
|
||||
transition: opacity $long-animation-time;
|
||||
@media (max-height: $min-height) { display: none; }
|
||||
&.transparent { opacity: 0; pointer-events: none; }
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
text-align: center;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
.exit {
|
||||
position: absolute;
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
strong { font-weight: bold; }
|
||||
}
|
||||
|
||||
.confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
&:after { background-color: #b53f3f; }
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
button { width: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.trash {
|
||||
@include square(48px);
|
||||
padding: 16px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1500;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
margin: 0 !important;
|
||||
|
||||
transform: translateX(-50%) scale(0);
|
||||
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&.active {
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
216
frontend/src/app/components/page/page.component.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
inject,
|
||||
HostListener,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { TowerComponent } from '../tower/tower.component';
|
||||
import { ModalComponent } from '../modal/modal.component';
|
||||
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
|
||||
import {
|
||||
DoubleSliderComponent,
|
||||
DoubleSliderRange,
|
||||
} from '../shared/double-slider/double-slider.component';
|
||||
import { computed } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import { ModalStateService } from '../../services/modal-state.service';
|
||||
|
||||
// ── Relative-time helpers ──────────────────────────────────────────────────
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' });
|
||||
|
||||
function formatRelative(ts: number, nowSec: number): string {
|
||||
const diff = ts - nowSec; // negative = past
|
||||
const absDiff = Math.abs(diff);
|
||||
if (absDiff < 45) return rtf.format(Math.round(diff), 'second');
|
||||
if (absDiff < 60 * 45) return rtf.format(Math.round(diff / 60), 'minute');
|
||||
if (absDiff < 60 * 60 * 22) return rtf.format(Math.round(diff / 3600), 'hour');
|
||||
if (absDiff < 86400 * 26) return rtf.format(Math.round(diff / 86400), 'day');
|
||||
if (absDiff < 86400 * 320) return rtf.format(Math.round(diff / 86400 / 30), 'month');
|
||||
return rtf.format(Math.round(diff / 86400 / 365), 'year');
|
||||
}
|
||||
|
||||
interface BlockPatch {
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
const MIN_BLOCKS_FOR_SLIDER = 2;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TowerComponent,
|
||||
ModalComponent,
|
||||
TowerSettingsComponent,
|
||||
DoubleSliderComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './page.component.html',
|
||||
styleUrl: './page.component.scss',
|
||||
})
|
||||
export class PageComponent {
|
||||
readonly page = input.required<Page>();
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
readonly dragHappening = output<boolean>();
|
||||
|
||||
protected readonly store = inject(StoreService);
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
/** True while any lt-modal is mounted — used to lock tower drag. */
|
||||
readonly modalOpen = this.modalState.anyOpen;
|
||||
readonly mobileDragDisabled = signal(this.isMobileViewport());
|
||||
|
||||
readonly showAddTower = signal(false);
|
||||
readonly isDragging = signal(false);
|
||||
/** When set, opens a confirmation modal before the dragged tower is deleted. */
|
||||
readonly confirmDeleteTowerId = signal<string | null>(null);
|
||||
private draggedTowerId: string | null = null;
|
||||
private nearTrashcan = false;
|
||||
|
||||
readonly confirmDeleteTowerName = computed(() => {
|
||||
const id = this.confirmDeleteTowerId();
|
||||
if (!id) return '';
|
||||
return this.page().towers.find((t) => t.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
// ── Date-range slider state ────────────────────────────────────────────────
|
||||
|
||||
/** Sorted unique `created_at` timestamps (seconds) across all done blocks
|
||||
* in this page. Empty list when no blocks yet. */
|
||||
readonly blockDates = computed<number[]>(() => {
|
||||
const set = new Set<number>();
|
||||
for (const tower of this.page().towers) {
|
||||
for (const b of tower.blocks) if (b.is_done) set.add(b.created_at);
|
||||
}
|
||||
return [...set].sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
/** Date labels formatted for slider display (deduplicated, insertion order). */
|
||||
readonly dateLabels = computed<string[]>(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const t of this.blockDates()) {
|
||||
const lbl = formatRelative(t, now);
|
||||
if (!seen.has(lbl)) {
|
||||
seen.add(lbl);
|
||||
labels.push(lbl);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
});
|
||||
|
||||
readonly showSlider = computed(() => this.blockDates().length >= MIN_BLOCKS_FOR_SLIDER);
|
||||
|
||||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.mobileDragDisabled.set(this.isMobileViewport());
|
||||
}
|
||||
|
||||
private isMobileViewport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tower mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddTower(result: TowerSettingsResult): void {
|
||||
this.showAddTower.set(false);
|
||||
this.store.addTower(this.page().id, result.name, result.base_color);
|
||||
}
|
||||
|
||||
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
|
||||
this.store.updateTower(this.page().id, towerId, {
|
||||
name: result.name,
|
||||
base_color: result.base_color,
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddBlock(towerId: string, result: BlockPatch): void {
|
||||
this.store.addBlock(
|
||||
this.page().id,
|
||||
towerId,
|
||||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
result.difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
onSaveBlock(towerId: string, event: { blockId: string; result: BlockPatch }): void {
|
||||
this.store.updateBlock(this.page().id, towerId, event.blockId, event.result);
|
||||
}
|
||||
|
||||
onDeleteBlock(towerId: string, blockId: string): void {
|
||||
this.store.deleteBlock(this.page().id, towerId, blockId);
|
||||
}
|
||||
|
||||
// ── Drag-and-drop + trash ──────────────────────────────────────────────────
|
||||
|
||||
onTowerDragStart(towerId: string): void {
|
||||
this.draggedTowerId = towerId;
|
||||
this.isDragging.set(true);
|
||||
this.dragHappening.emit(true);
|
||||
}
|
||||
|
||||
onTowerDropped(event: CdkDragDrop<unknown>): void {
|
||||
this.isDragging.set(false);
|
||||
this.dragHappening.emit(false);
|
||||
if (this.nearTrashcan && this.draggedTowerId) {
|
||||
// Open confirm dialog instead of deleting immediately.
|
||||
this.confirmDeleteTowerId.set(this.draggedTowerId);
|
||||
} else if (event.previousIndex !== event.currentIndex) {
|
||||
this.store.reorderTowers(this.page().id, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
this.draggedTowerId = null;
|
||||
this.nearTrashcan = false;
|
||||
}
|
||||
|
||||
confirmTowerDelete(): void {
|
||||
const id = this.confirmDeleteTowerId();
|
||||
if (id) this.store.deleteTower(this.page().id, id);
|
||||
this.confirmDeleteTowerId.set(null);
|
||||
}
|
||||
|
||||
cancelTowerDelete(): void {
|
||||
this.confirmDeleteTowerId.set(null);
|
||||
}
|
||||
|
||||
onTrashEnter(): void {
|
||||
this.nearTrashcan = true;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.add('trash-highlight');
|
||||
}
|
||||
|
||||
onTrashLeave(): void {
|
||||
this.nearTrashcan = false;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.remove('trash-highlight');
|
||||
}
|
||||
}
|
||||
71
frontend/src/app/components/pages/pages.component.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<div class="select-add-container">
|
||||
<lt-select-add
|
||||
[options]="pageNames()"
|
||||
[selectedIndex]="selectedPageIndex()"
|
||||
placeholder="Add a new page…"
|
||||
(selectionChange)="onSelectPage($event)"
|
||||
(add)="onAddPage($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="page-container">
|
||||
@if (selectedPage(); as page) {
|
||||
<lt-page
|
||||
[page]="page"
|
||||
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||
(dragHappening)="dragHappening.set($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>Add a new page to get started!</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button [class.transparent]="dragHappening()" (click)="showSettings.set(true)">
|
||||
Settings
|
||||
</button>
|
||||
|
||||
@if (showSettings()) {
|
||||
<lt-modal (close)="showSettings.set(false)">
|
||||
<lt-settings
|
||||
[token]="store.token()"
|
||||
[page]="selectedPage()"
|
||||
(close)="showSettings.set(false)"
|
||||
(updatePage)="onUpdatePage($event)"
|
||||
(deletePage)="onRequestRemovePage()"
|
||||
(switchAccount)="onSwitchAccount($event)"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (confirmDeletePageId()) {
|
||||
<lt-modal (close)="cancelRemovePage()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<button class="exit" type="button" (click)="cancelRemovePage()" aria-label="Cancel"></button>
|
||||
<h2>Delete page</h2>
|
||||
</div>
|
||||
<p>
|
||||
Delete <strong>{{ confirmDeletePageName() || 'this page' }}</strong> and all of its towers and blocks?
|
||||
This can't be undone.
|
||||
</p>
|
||||
<div class="confirm-buttons">
|
||||
<button type="button" (click)="cancelRemovePage()">Cancel</button>
|
||||
<button type="button" class="danger" (click)="confirmRemovePage()">Delete page</button>
|
||||
</div>
|
||||
</div>
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (showWelcome()) {
|
||||
<lt-modal
|
||||
labelledBy="welcome-title"
|
||||
describedBy="welcome-description"
|
||||
(close)="showWelcome.set(false)"
|
||||
>
|
||||
<lt-welcome
|
||||
(close)="showWelcome.set(false)"
|
||||
(startFresh)="showWelcome.set(false)"
|
||||
(loadExample)="onLoadExample()"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
108
frontend/src/app/components/pages/pages.component.scss
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
@import '../../../styles';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.select-add-container {
|
||||
width: 250px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 80vw;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
// Generous breathing room between the page selector dropdown and the
|
||||
// towers — the dropdown can open downward without crowding the towers.
|
||||
padding-top: var(--large-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
padding-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
transition: opacity $long-animation-time;
|
||||
|
||||
&.transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-top: var(--medium-padding);
|
||||
font-size: var(--medium-font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
text-align: center;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
|
||||
&:after {
|
||||
background-color: #b53f3f;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
frontend/src/app/components/pages/pages.component.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { PageComponent } from '../page/page.component';
|
||||
import { ModalComponent } from '../modal/modal.component';
|
||||
import { SettingsComponent, UpdatePagePayload } from '../modal/settings.component';
|
||||
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
||||
import { WelcomeComponent } from '../welcome/welcome.component';
|
||||
import { Page } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-pages',
|
||||
standalone: true,
|
||||
imports: [PageComponent, ModalComponent, SettingsComponent, SelectAddComponent, WelcomeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './pages.component.html',
|
||||
styleUrl: './pages.component.scss',
|
||||
})
|
||||
export class PagesComponent implements OnDestroy {
|
||||
protected readonly store = inject(StoreService);
|
||||
|
||||
/** ID of currently selected page within store.pages(). */
|
||||
private readonly selectedPageId = signal<string | null>(null);
|
||||
|
||||
readonly showSettings = signal(false);
|
||||
readonly dragHappening = signal(false);
|
||||
readonly showWelcome = signal(false);
|
||||
readonly confirmDeletePageId = signal<string | null>(null);
|
||||
readonly animateInitialStackPageId = signal<string | null>(null);
|
||||
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.store.loading() && this.store.pages().length === 0) {
|
||||
this.showWelcome.set(true);
|
||||
} else if (this.store.pages().length > 0) {
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onLoadExample(): void {
|
||||
const pageId = this.store.loadExample();
|
||||
this.selectedPageId.set(pageId);
|
||||
this.animateInitialStackPageId.set(pageId);
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
this.exampleAnimationTimer = setTimeout(() => {
|
||||
if (this.animateInitialStackPageId() === pageId) {
|
||||
this.animateInitialStackPageId.set(null);
|
||||
}
|
||||
this.exampleAnimationTimer = null;
|
||||
}, 2500);
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
}
|
||||
|
||||
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
||||
|
||||
readonly selectedPage = computed<Page | null>(() => {
|
||||
const pages = this.store.pages();
|
||||
if (pages.length === 0) return null;
|
||||
const id = this.selectedPageId();
|
||||
if (id) {
|
||||
const found = pages.find((p) => p.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
// Default to first page.
|
||||
return pages[0] ?? null;
|
||||
});
|
||||
|
||||
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
|
||||
|
||||
readonly confirmDeletePageName = computed(() => {
|
||||
const id = this.confirmDeletePageId();
|
||||
if (!id) return '';
|
||||
return this.store.pages().find((p) => p.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
readonly selectedPageIndex = computed(() => {
|
||||
const page = this.selectedPage();
|
||||
if (!page) return -1;
|
||||
return this.store.pages().findIndex((p) => p.id === page.id);
|
||||
});
|
||||
|
||||
onSelectPage(index: number): void {
|
||||
const pages = this.store.pages();
|
||||
const page = pages[index];
|
||||
if (page) {
|
||||
this.selectedPageId.set(page.id);
|
||||
}
|
||||
}
|
||||
|
||||
onAddPage(name: string): void {
|
||||
this.store.addPage(name);
|
||||
// Select the newly added page.
|
||||
const pages = this.store.pages();
|
||||
const newPage = pages[pages.length - 1];
|
||||
if (newPage) {
|
||||
this.selectedPageId.set(newPage.id);
|
||||
}
|
||||
}
|
||||
|
||||
onUpdatePage(payload: UpdatePagePayload): void {
|
||||
const page = this.selectedPage();
|
||||
if (page) {
|
||||
this.store.updatePage(page.id, payload);
|
||||
}
|
||||
}
|
||||
|
||||
onRequestRemovePage(): void {
|
||||
const page = this.selectedPage();
|
||||
if (!page) return;
|
||||
this.confirmDeletePageId.set(page.id);
|
||||
}
|
||||
|
||||
confirmRemovePage(): void {
|
||||
const pageId = this.confirmDeletePageId();
|
||||
if (!pageId) return;
|
||||
this.store.deletePage(pageId);
|
||||
this.selectedPageId.set(null);
|
||||
this.showSettings.set(false);
|
||||
this.confirmDeletePageId.set(null);
|
||||
}
|
||||
|
||||
cancelRemovePage(): void {
|
||||
this.confirmDeletePageId.set(null);
|
||||
}
|
||||
|
||||
onSwitchAccount(token: string): void {
|
||||
this.store.switchToken(token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { HslColor } from '../../../models';
|
||||
import { toCss } from '../../../utils/color';
|
||||
|
||||
// 12 hand-picked hues. Rationale:
|
||||
// – Warm cluster (0–45°): coral/red, orange-red, orange, amber — vivid warm tones
|
||||
// – Skipped 60–180° (yellows + greens) — most read as muddy next to the rose UI accent
|
||||
// – Cool cluster (195–260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
|
||||
// – Purple-rose cluster (280–355°): violet, magenta, rose-pink, near-red — complements the accent
|
||||
const PRESETS: number[] = [0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355];
|
||||
|
||||
const FIXED_S = 0.7;
|
||||
const FIXED_L = 0.55;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-color-picker',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="picker">
|
||||
<div class="swatches" role="group" aria-label="Preset colors">
|
||||
@for (h of presetHues; track h) {
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
[class.active]="isActiveHue(h)"
|
||||
[style.background-color]="hueToCss(h)"
|
||||
[attr.aria-label]="'Pick hue ' + h + ' degrees'"
|
||||
[attr.aria-pressed]="isActiveHue(h)"
|
||||
(click)="pickHue(h)"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hue-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
[value]="hueDeg()"
|
||||
(input)="onSlider($any($event.target).value)"
|
||||
[style.--thumb-color]="toCss(color())"
|
||||
aria-label="Hue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="preview" [style.background-color]="toCss(color())" aria-hidden="true"></div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../../library/main';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
padding: var(--medium-padding);
|
||||
@include card();
|
||||
border: 1px solid rgba($text-color, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba($light-color, 0.7);
|
||||
background-color: rgba($text-color, 0.025);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--medium-padding);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
padding: 8px 0;
|
||||
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
border-radius: 1000px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsl(0, 70%, 55%),
|
||||
hsl(60, 70%, 55%),
|
||||
hsl(120, 70%, 55%),
|
||||
hsl(180, 70%, 55%),
|
||||
hsl(240, 70%, 55%),
|
||||
hsl(300, 70%, 55%),
|
||||
hsl(360, 70%, 55%)
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, #{$light-color});
|
||||
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, white);
|
||||
border: 2px solid white;
|
||||
box-shadow: $shadow;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ColorPickerComponent {
|
||||
readonly color = input.required<HslColor>();
|
||||
readonly colorChange = output<HslColor>();
|
||||
|
||||
readonly presetHues = PRESETS;
|
||||
|
||||
readonly hueDeg = computed(() => Math.round(this.color().h * 360));
|
||||
|
||||
isActiveHue(h: number): boolean {
|
||||
return Math.abs(this.hueDeg() - h) < 8;
|
||||
}
|
||||
|
||||
hueToCss(h: number): string {
|
||||
return `hsl(${h}, 70%, 55%)`;
|
||||
}
|
||||
|
||||
toCss(c: HslColor): string {
|
||||
return toCss(c);
|
||||
}
|
||||
|
||||
pickHue(h: number): void {
|
||||
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
||||
}
|
||||
|
||||
onSlider(value: string): void {
|
||||
this.colorChange.emit({ h: Number(value) / 360, s: FIXED_S, l: FIXED_L });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface DoubleSliderRange<T> {
|
||||
from: T;
|
||||
to: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-thumb range slider — legacy "double-slider".
|
||||
* Hands an indexed range over an arbitrary values array; emits the
|
||||
* underlying values on each change. Labels magnetically lift as a thumb
|
||||
* approaches them (rotated -45°), per the legacy.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lt-double-slider',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="container">
|
||||
<label for="ds-1">From</label>
|
||||
<label for="ds-2">To</label>
|
||||
<input
|
||||
id="ds-1"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[value]="oneValue()"
|
||||
(input)="oneValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
<input
|
||||
id="ds-2"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[value]="otherValue()"
|
||||
(input)="otherValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
<div class="value-container">
|
||||
@for (i of drawnIndices(); track i) {
|
||||
<span [style.transform]="getOffset(i)">{{ drawnLabels()[i] }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../../library/main';
|
||||
|
||||
$line-height: 2px;
|
||||
$height: 90px;
|
||||
$slider-size: 40px;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: $height;
|
||||
position: relative;
|
||||
margin: calc(#{$slider-size} / 2) auto 0 auto;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
max-width: 90vw;
|
||||
margin-top: calc(#{$slider-size} / 2);
|
||||
}
|
||||
|
||||
label { display: none; }
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: $slider-size;
|
||||
width: $slider-size;
|
||||
border-radius: 1000px;
|
||||
background-color: $light-color;
|
||||
box-shadow: $shadow-border;
|
||||
transform-origin: center center;
|
||||
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2));
|
||||
transition: box-shadow $long-animation-time, transform $long-animation-time;
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover {
|
||||
box-shadow: $shadow;
|
||||
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2))
|
||||
scale(1.1);
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
height: $slider-size;
|
||||
width: $slider-size;
|
||||
border-radius: 1000px;
|
||||
background-color: $light-color;
|
||||
border: none;
|
||||
box-shadow: $shadow-border;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: $line-height;
|
||||
background-color: $text-color;
|
||||
border-radius: 1000px;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
-moz-appearance: none;
|
||||
width: 100%;
|
||||
height: $line-height;
|
||||
background-color: $text-color;
|
||||
border-radius: 1000px;
|
||||
}
|
||||
|
||||
&::-moz-focus-outer { border: 0; }
|
||||
}
|
||||
|
||||
.value-container {
|
||||
font-family: $normal-font;
|
||||
color: $text-color;
|
||||
font-size: var(--medium-font-size);
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
margin-top: calc(#{$slider-size} + 8px);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
transform-origin: center bottom;
|
||||
transition: transform $long-animation-time;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: var(--small-font-size);
|
||||
margin-top: $slider-size;
|
||||
span { margin-top: 10px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DoubleSliderComponent {
|
||||
/** Ordered list of underlying values (e.g. dates). */
|
||||
readonly values = input.required<unknown[]>();
|
||||
/** Display labels for evenly-spaced ticks (≤ values.length). */
|
||||
readonly labels = input.required<string[]>();
|
||||
|
||||
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
||||
|
||||
readonly MAX = 100;
|
||||
|
||||
readonly oneValue = signal(0);
|
||||
readonly otherValue = signal(this.MAX - 1);
|
||||
|
||||
private prevValuesLength = 0;
|
||||
|
||||
readonly drawnLabels = computed(() => {
|
||||
const labels = this.labels();
|
||||
const count = Math.min(labels.length, 6);
|
||||
if (count === 0) return [] as string[];
|
||||
const jump = Math.max(1, Math.round(labels.length / count));
|
||||
return labels.filter((_, i) => i % jump === 0);
|
||||
});
|
||||
|
||||
readonly drawnIndices = computed(() =>
|
||||
Array.from({ length: this.drawnLabels().length }, (_, i) => i),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Re-emit the value range whenever the slider thumbs or values change.
|
||||
effect(() => {
|
||||
const a = this.oneValue();
|
||||
const b = this.otherValue();
|
||||
const vs = this.values();
|
||||
if (vs.length === 0) return;
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
untracked(() => {
|
||||
this.rangeChange.emit({
|
||||
from: vs[this.indexFromValue(lo)],
|
||||
to: vs[this.indexFromValue(hi)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Snap the higher thumb to MAX - 1 when a new entry is appended.
|
||||
effect(() => {
|
||||
const len = this.values().length;
|
||||
untracked(() => {
|
||||
if (len > this.prevValuesLength) {
|
||||
const a = this.oneValue();
|
||||
const b = this.otherValue();
|
||||
if (a > b) this.oneValue.set(this.MAX - 1);
|
||||
else this.otherValue.set(this.MAX - 1);
|
||||
}
|
||||
this.prevValuesLength = len;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private indexFromValue(value: number): number {
|
||||
return Math.min(
|
||||
this.values().length - 1,
|
||||
Math.floor((value / this.MAX) * this.values().length),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetic label position: returns a CSS `transform` that lifts the label
|
||||
* upward and rotates -45° as a thumb approaches.
|
||||
*/
|
||||
getOffset(index: number): string {
|
||||
const labelIndex = index / Math.max(1, this.drawnLabels().length);
|
||||
const a = this.oneValue() / this.MAX - 0.1;
|
||||
const b = this.otherValue() / this.MAX - 0.1;
|
||||
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
||||
const ACTIVE_ZONE = 0.2;
|
||||
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
||||
if (dist > ACTIVE_ZONE) return base;
|
||||
const lift = ((ACTIVE_ZONE - dist) / ACTIVE_ZONE) * 36;
|
||||
return `translateY(${lift}px) ${base}`;
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/components/shared/icon/icon.component.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
|
||||
|
||||
export type IconName = 'arrow' | 'pen' | 'plus-sign' | 'trash' | 'x-sign';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-icon',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<img
|
||||
[src]="'assets/' + name() + '.svg'"
|
||||
[alt]="name()"
|
||||
[style.width]="size()"
|
||||
[style.height]="size()"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class IconComponent {
|
||||
readonly name = input.required<IconName>();
|
||||
readonly size = input<string>('1.25rem');
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-select-add',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="container"
|
||||
[class.shadow-border]="onlyShadowBorder()"
|
||||
[class.always-shadow]="alwaysDropShadow()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div class="top" (click)="toggleOpen($event)">
|
||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||
</div>
|
||||
<div class="bottom-container">
|
||||
<div class="bottom" [class.open]="open()">
|
||||
@for (item of resolvedItems(); track item) {
|
||||
@if (editing()) {
|
||||
<input
|
||||
type="text"
|
||||
[value]="item"
|
||||
(blur)="onRename(item, $any($event.target).value)"
|
||||
/>
|
||||
} @else {
|
||||
<button class="option" type="button" (click)="onSelectItem(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="add-row">
|
||||
<input
|
||||
type="text"
|
||||
#addInput
|
||||
placeholder="Add a value…"
|
||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
||||
/>
|
||||
<button
|
||||
class="add-button"
|
||||
type="button"
|
||||
(click)="onAdd(addInput.value); addInput.value = ''"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@if (editable()) {
|
||||
<button
|
||||
class="pen"
|
||||
type="button"
|
||||
[class.active]="editing()"
|
||||
(click)="editing.update(v => !v)"
|
||||
>
|
||||
<img src="assets/pen.svg" alt="Edit" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../../library/main';
|
||||
|
||||
$inner-padding: var(--medium-padding);
|
||||
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
padding: $inner-padding;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
min-height: 46px;
|
||||
box-sizing: border-box;
|
||||
gap: var(--small-padding);
|
||||
|
||||
p {
|
||||
display: block;
|
||||
@include sub-title-text();
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
img.arrow {
|
||||
flex: 0 0 auto;
|
||||
@include square(16px);
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&.upside-down {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-container {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
padding: $inner-padding;
|
||||
padding-top: var(--small-padding);
|
||||
gap: var(--small-padding);
|
||||
// Default (closed) state — also the target of the close transition.
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
transform: translateY(-8px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
// Clip the top edge so the panel's shadow can't bleed back up into
|
||||
// the chip area; sides + bottom get a 10px slack for the shadow.
|
||||
clip-path: inset(0 -10px -10px -10px);
|
||||
// Delay the visibility change until after the slide animation finishes
|
||||
// so the panel stays visible while it animates closed.
|
||||
transition:
|
||||
transform $long-animation-time,
|
||||
opacity $long-animation-time,
|
||||
background-color $long-animation-time,
|
||||
box-shadow $long-animation-time,
|
||||
visibility 0s $long-animation-time;
|
||||
|
||||
&.open {
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
background-color: $light-color;
|
||||
box-shadow: $dropdown-shadow;
|
||||
// On open, visibility flips immediately (no delay); transform +
|
||||
// colors + shadow animate over $long-animation-time.
|
||||
transition:
|
||||
transform $long-animation-time,
|
||||
opacity $long-animation-time,
|
||||
background-color $long-animation-time,
|
||||
box-shadow $long-animation-time,
|
||||
visibility 0s 0s;
|
||||
}
|
||||
|
||||
.option {
|
||||
@include sub-title-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
@include sub-title-text();
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba($text-color, 0.72);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-row {
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--small-padding);
|
||||
|
||||
input[type='text'] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-bottom: solid 2px transparent;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
border-bottom-color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.add-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&.pen {
|
||||
opacity: 0.25;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
|
||||
// Kill the global button's hover-grow underline pseudo-element
|
||||
// for the icon-only edit control.
|
||||
&:after { content: none; display: none; }
|
||||
|
||||
img {
|
||||
@include square(16px);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background-color: $text-color;
|
||||
width: 0;
|
||||
transition: width $long-animation-time;
|
||||
}
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover { opacity: 0.5; }
|
||||
&:hover:before { width: 100% !important; }
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
&:before { width: 100% !important; }
|
||||
}
|
||||
|
||||
transition: opacity $short-animation-time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@include card();
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
box-shadow $long-animation-time,
|
||||
height $long-animation-time,
|
||||
border-radius $long-animation-time;
|
||||
|
||||
&.active {
|
||||
// Same shadow recipe as the panel below so the two halves read as
|
||||
// one continuous card. Clip the bottom edge so this shadow doesn't
|
||||
// bleed across the seam where .top meets .bottom.
|
||||
box-shadow: $dropdown-shadow;
|
||||
clip-path: inset(-10px -10px 0 -10px);
|
||||
}
|
||||
}
|
||||
|
||||
.top {
|
||||
transition: border-radius $long-animation-time;
|
||||
}
|
||||
|
||||
// When the drawer is open, square the BOTTOM corners on both the
|
||||
// background card and the top chip so they merge into one continuous
|
||||
// surface. (Animated via the border-radius transitions above.)
|
||||
&:has(.bottom.open) {
|
||||
.top,
|
||||
.background {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hover lifts the chip — but only when the dropdown is closed. When
|
||||
// it's open the chip is already showing $dropdown-shadow and a hover
|
||||
// override would make the top heavier than the panel below.
|
||||
&:hover {
|
||||
@media (min-width: $mobile-width) {
|
||||
.background:not(.active) { box-shadow: $shadow; }
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border {
|
||||
.background.active {
|
||||
box-shadow: $shadow-border;
|
||||
clip-path: inset(-10px -10px 0 -10px);
|
||||
}
|
||||
.bottom.open {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border:hover {
|
||||
.background:not(.active) { box-shadow: $shadow-border; }
|
||||
}
|
||||
|
||||
&.always-shadow {
|
||||
.background:not(.active) { box-shadow: $shadow; }
|
||||
// When open, clip the bottom so the always-on shadow doesn't bleed
|
||||
// over the seam; restore full shadow when closed.
|
||||
&:has(.bottom.open) .background {
|
||||
clip-path: inset(-6px -6px 0 -6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SelectAddComponent implements OnChanges {
|
||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||
/** List of string options */
|
||||
readonly items = input<string[]>([]);
|
||||
/** Currently selected string value */
|
||||
readonly selected = input<string | null>(null);
|
||||
readonly editable = input<boolean>(false);
|
||||
readonly placeholder = input<string>('Select…');
|
||||
readonly alwaysDropShadow = input<boolean>(false);
|
||||
readonly onlyShadowBorder = input<boolean>(false);
|
||||
|
||||
// ── Legacy compat API (used by pages.component.html until Agent B updates) ─
|
||||
/** @deprecated Use items instead */
|
||||
readonly options = input<string[]>([]);
|
||||
/** @deprecated Use selected (string) instead */
|
||||
readonly selectedIndex = input<number>(-1);
|
||||
|
||||
// ── Outputs — new API ──────────────────────────────────────────────────────
|
||||
readonly select = output<string>();
|
||||
readonly add = output<string>();
|
||||
readonly rename = output<{ old: string; new: string }>();
|
||||
readonly remove = output<string>();
|
||||
|
||||
// ── Legacy compat outputs ──────────────────────────────────────────────────
|
||||
/** @deprecated Use select instead */
|
||||
readonly selectionChange = output<number>();
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────────
|
||||
readonly open = signal(false);
|
||||
readonly editing = signal(false);
|
||||
private readonly host = inject(ElementRef<HTMLElement>);
|
||||
|
||||
// Resolved values that merge old + new API
|
||||
protected resolvedItems(): string[] {
|
||||
const newItems = this.items();
|
||||
const oldOptions = this.options();
|
||||
return newItems.length ? newItems : oldOptions;
|
||||
}
|
||||
|
||||
protected resolvedSelected(): string | null {
|
||||
// New API: string
|
||||
const s = this.selected();
|
||||
if (s != null && s.trim()) return s;
|
||||
// Legacy API: index into options
|
||||
const idx = this.selectedIndex();
|
||||
const opts = this.resolvedItems();
|
||||
if (idx >= 0 && idx < opts.length) return opts[idx];
|
||||
return null;
|
||||
}
|
||||
|
||||
ngOnChanges(_changes: SimpleChanges): void {
|
||||
// Nothing to do — signals handle reactivity
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.open()) return;
|
||||
if (!this.host.nativeElement.contains(event.target as Node)) {
|
||||
this.open.set(false);
|
||||
this.editing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleOpen(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.open.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectItem(item: string): void {
|
||||
this.select.emit(item);
|
||||
// Legacy compat: also emit the index
|
||||
const idx = this.resolvedItems().indexOf(item);
|
||||
if (idx >= 0) this.selectionChange.emit(idx);
|
||||
this.open.set(false);
|
||||
this.editing.set(false);
|
||||
}
|
||||
|
||||
onAdd(value: string): void {
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
this.add.emit(v);
|
||||
this.open.set(false);
|
||||
}
|
||||
|
||||
onRename(oldValue: string, newValue: string): void {
|
||||
const n = newValue.trim();
|
||||
if (n && n !== oldValue) {
|
||||
this.rename.emit({ old: oldValue, new: n });
|
||||
}
|
||||
}
|
||||
}
|
||||
119
frontend/src/app/components/shared/toggle/toggle.component.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
model,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-toggle',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="toggle">
|
||||
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[class.on]="checked()"
|
||||
[checked]="checked()"
|
||||
(change)="set(!checked())"
|
||||
/>
|
||||
</label>
|
||||
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../../library/main';
|
||||
|
||||
:host {
|
||||
$size: 30px;
|
||||
|
||||
@include center-child();
|
||||
gap: var(--medium-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 100%;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
span {
|
||||
@include medium-text();
|
||||
// Fixed width (not max-width) so multiple toggles align column-wise
|
||||
// — the thumb position is identical across rows regardless of label.
|
||||
flex: 0 0 auto;
|
||||
width: var(--toggle-label-width, #{4 * $size});
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--small-padding);
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
|
||||
&.active { font-weight: bold; }
|
||||
&:first-of-type { text-align: right; }
|
||||
&:last-of-type { text-align: left; }
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
input[type='checkbox'] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 2 * $size;
|
||||
height: $size;
|
||||
border-radius: 1000px;
|
||||
box-shadow: $shadow-border;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
@include square($size);
|
||||
border-radius: 1000px;
|
||||
background-color: $text-color;
|
||||
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
|
||||
}
|
||||
|
||||
&.on:after { left: $size; }
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover:after {
|
||||
box-shadow: $shadow;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
&.on:hover:after {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ToggleComponent {
|
||||
readonly checked = model<boolean>(false);
|
||||
readonly offLabel = input<string>('No');
|
||||
readonly onLabel = input<string>('Yes');
|
||||
|
||||
set(value: boolean): void {
|
||||
this.checked.set(value);
|
||||
}
|
||||
}
|
||||
252
frontend/src/app/components/tasks/tasks.component.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
/**
|
||||
* Tasks accordion — shows pending (not-done) blocks inside a tower.
|
||||
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
|
||||
* Clicking the colored tickbox marks the task done.
|
||||
* Clicking the description opens the block-edit modal via the `edit` output.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lt-tasks',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="container"
|
||||
[class.show-hover]="pending().length > 0"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(touchstart)="$event.stopPropagation()"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<p
|
||||
class="header"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
<div
|
||||
#all
|
||||
class="all-task"
|
||||
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
|
||||
>
|
||||
@for (b of pending(); track b.id) {
|
||||
<div class="task-container">
|
||||
<button
|
||||
type="button"
|
||||
class="tickbox"
|
||||
[style.background-color]="colorOf(b.tag)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); markDone.emit(b)"
|
||||
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
||||
></button>
|
||||
<p
|
||||
[style.color]="colorOf(b.tag)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||
>{{ b.description || b.tag }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
// Within the tower stacking context: high enough to float above the
|
||||
// falling-blocks layer. Globally low enough that modals + the carousel
|
||||
// (10000+) always cover us.
|
||||
z-index: 5;
|
||||
|
||||
.container {
|
||||
@include card();
|
||||
|
||||
cursor: pointer;
|
||||
transition: box-shadow $long-animation-time;
|
||||
&.show-hover:hover {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
padding: calc(var(--small-padding) / 2);
|
||||
margin: calc(var(--small-padding) / 2);
|
||||
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.header { cursor: pointer; }
|
||||
|
||||
p { font-size: var(--medium-font-size); }
|
||||
|
||||
.all-task {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
:first-child { margin-top: var(--small-padding); }
|
||||
|
||||
box-sizing: border-box;
|
||||
transition: max-height $long-animation-time;
|
||||
|
||||
/*
|
||||
* Clip during the open/close animation. We animate to the element's
|
||||
* exact content height (read off #all in the template) so tall lists
|
||||
* simply scroll inside the outer .container (max-height: 30vh) —
|
||||
* .all-task itself is NOT a nested scroller. A nested overflow-y:auto
|
||||
* here pops a scrollbar the instant a tickbox grows on hover, because
|
||||
* its scale(1.05) + shadow widen the scrollable-overflow box.
|
||||
*/
|
||||
overflow: hidden;
|
||||
|
||||
// Sideways breathing room so the clip doesn't shear the tickbox's
|
||||
// hover shadow; negative side margins keep rows flush with the header,
|
||||
// and the bottom padding clears the last row's shadow.
|
||||
margin: 0 calc(var(--small-padding) / -2);
|
||||
padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2);
|
||||
|
||||
.task-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--small-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
gap: calc(var(--small-padding) / 2);
|
||||
}
|
||||
|
||||
&:hover p {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Tickbox: a generously sized colored button that marks the task
|
||||
// done without opening the edit carousel. Hover & focus reveal a
|
||||
// subtle inner check mark.
|
||||
.tickbox {
|
||||
all: unset; // strip native button styles
|
||||
flex: 0 0 24px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@include square(24px);
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(24px);
|
||||
}
|
||||
border-radius: 4px;
|
||||
box-shadow: $shadow-border;
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
|
||||
&::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/*
|
||||
* Neutralise the global animated-underline bar from
|
||||
* forms.scss (button:after { height: 2px; width: 0->100% on
|
||||
* hover; background-color: $text-color }). The all:unset on the
|
||||
* button does NOT reach the pseudo-element, so without these
|
||||
* resets the bar paints a dark stripe across the top AND
|
||||
* squashes this box to 2px — which centres the glyph near the
|
||||
* top instead of the middle.
|
||||
*/
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: none;
|
||||
@include center-child();
|
||||
color: $light-color;
|
||||
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
|
||||
opacity: 0; // hidden at rest — only revealed on hover/focus/active
|
||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
|
||||
// The ✓ glyph sits a touch high in its em-box; nudge to optical centre.
|
||||
transform: translateY(1px);
|
||||
transition: opacity $short-animation-time, transform $short-animation-time;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
transform: scale(1.05);
|
||||
&::after { opacity: 0.85; }
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: var(--medium-font-size);
|
||||
font-weight: 500;
|
||||
filter: saturate(0.85) brightness(0.82);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TasksComponent {
|
||||
readonly pending = input.required<Block[]>();
|
||||
readonly baseColor = input.required<HslColor>();
|
||||
/** When true, the accordion starts expanded on first render. */
|
||||
readonly initiallyOpen = input<boolean>(false);
|
||||
|
||||
/** Emitted when the colored tickbox is clicked — parent flips is_done to true. */
|
||||
readonly markDone = output<Block>();
|
||||
/** Emitted when the description is clicked — parent opens the block-edit modal. */
|
||||
readonly edit = output<Block>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private lastToggleAt = 0;
|
||||
|
||||
constructor() {
|
||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
||||
// the "Keep tasks open" page setting expands/collapses the accordion live.
|
||||
// User clicks (which mutate `expanded` directly) are respected until the
|
||||
// setting changes again.
|
||||
effect(() => {
|
||||
const open = this.initiallyOpen();
|
||||
untracked(() => this.expanded.set(open));
|
||||
});
|
||||
}
|
||||
|
||||
colorOf(tag: string): string {
|
||||
return getColorOfTag(tag, this.baseColor());
|
||||
}
|
||||
|
||||
toggleExpanded(event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'touchend') {
|
||||
event.preventDefault();
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastToggleAt < 250) return;
|
||||
this.lastToggleAt = now;
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
}
|
||||
897
frontend/src/app/components/tower/tower.component.ts
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Tower, Block } from '../../models';
|
||||
import { BlockComponent } from '../block/block.component';
|
||||
import { TasksComponent } from '../tasks/tasks.component';
|
||||
import { BlockEditComponent, BlockEditSave } from '../modal/block-edit.component';
|
||||
import { ModalComponent } from '../modal/modal.component';
|
||||
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
|
||||
import { toCss } from '../../utils/color';
|
||||
|
||||
/** Tracks which entry path the block-edit modal was opened from. */
|
||||
interface EditEntry {
|
||||
filter: 'done' | 'pending';
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
/** A done block augmented with per-render animation state. */
|
||||
export interface StyledBlock extends Block {
|
||||
_anim: '' | 'descend' | 'ascend';
|
||||
_transform: string;
|
||||
_opacity: string;
|
||||
}
|
||||
|
||||
/** One rendered square. A block draws `difficulty` squares, all sharing the
|
||||
* block's color + animation state. */
|
||||
interface RenderSquare {
|
||||
key: string;
|
||||
block: StyledBlock;
|
||||
}
|
||||
|
||||
const BLOCKS_PER_ROW = 6;
|
||||
|
||||
/** How many squares a block draws (its difficulty, clamped to >= 1). */
|
||||
function squareCount(block: Block): number {
|
||||
const n = Math.floor(block.difficulty ?? 1);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
function totalSquares(blocks: Block[]): number {
|
||||
return blocks.reduce((sum, block) => sum + squareCount(block), 0);
|
||||
}
|
||||
|
||||
/** Pick the newest blocks (array tail) whose cumulative square-count (each
|
||||
* block costs `difficulty` squares) fits within `limit`, preserving order. */
|
||||
function fitNewestBySquares(blocks: StyledBlock[], limit: number): StyledBlock[] {
|
||||
const chosen: StyledBlock[] = [];
|
||||
let used = 0;
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const cost = squareCount(blocks[i]);
|
||||
if (used + cost > limit) break;
|
||||
used += cost;
|
||||
chosen.unshift(blocks[i]);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function selectVisibleStyledBlocks(
|
||||
styled: StyledBlock[],
|
||||
visibleLimit: number,
|
||||
enteringInRangeId: string | null,
|
||||
prevVisibleIds: ReadonlySet<string> = new Set(),
|
||||
): { visibleStyled: StyledBlock[]; hiddenCount: number } {
|
||||
// `visibleLimit` is a number of SQUARE slots. A block draws `difficulty`
|
||||
// squares, so we cap by cumulative square cost, not by raw block count.
|
||||
const normalizedLimit = Math.max(0, visibleLimit);
|
||||
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||
let shownRestingBlocks = fitNewestBySquares(restingBlocks, normalizedLimit);
|
||||
const enteringBlock =
|
||||
enteringInRangeId === null ? undefined : restingBlocks.find((b) => b.id === enteringInRangeId);
|
||||
|
||||
if (enteringBlock && !shownRestingBlocks.some((b) => b.id === enteringBlock.id)) {
|
||||
// Guarantee the just-completed block a slot, then fill the remaining
|
||||
// square budget with the newest of the others.
|
||||
const reservedBudget = Math.max(0, normalizedLimit - squareCount(enteringBlock));
|
||||
const others = restingBlocks.filter((b) => b.id !== enteringBlock.id);
|
||||
shownRestingBlocks = [enteringBlock, ...fitNewestBySquares(others, reservedBudget)];
|
||||
}
|
||||
|
||||
const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
|
||||
|
||||
// Blocks leaving past the upper date bound (opacity 0, `_anim: 'ascend'`) must
|
||||
// stay in the render list so their fly-up transition actually plays — even
|
||||
// when the resting stack already fills the whole square budget. Without this,
|
||||
// a capped stack (e.g. the example page) destroys the element the instant the
|
||||
// slider hides it and it just vanishes. Restrict to blocks that were visible a
|
||||
// moment ago: ones already off-screen have nothing to animate from, so leaving
|
||||
// them out keeps the rendered set (and the phantom flex slots) bounded.
|
||||
const exitingBlocks = styled.filter((b) => b._opacity !== '1' && prevVisibleIds.has(b.id));
|
||||
|
||||
const shownIds = new Set([
|
||||
...shownRestingBlocks.map((b) => b.id),
|
||||
...exitingBlocks.map((b) => b.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
visibleStyled: styled.filter((b) => shownIds.has(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-tower',
|
||||
standalone: true,
|
||||
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div #towerRoot class="tower">
|
||||
<div class="tower-header">
|
||||
<input
|
||||
#nameInput
|
||||
type="text"
|
||||
[value]="tower().name"
|
||||
[style.color]="towerNameCss()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(blur)="onRename($event)"
|
||||
(keydown.enter)="nameInput.blur()"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="edit-tower"
|
||||
aria-label="Edit tower"
|
||||
(click)="$event.stopPropagation(); showSettings.set(true)"
|
||||
>
|
||||
<img src="assets/pen.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="container"
|
||||
[class.trash-highlight]="trashHighlight()"
|
||||
>
|
||||
<lt-tasks
|
||||
[pending]="pending()"
|
||||
[baseColor]="tower().base_color"
|
||||
[initiallyOpen]="keepTasksOpen()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(markDone)="onMarkTaskDone($event)"
|
||||
(edit)="onEditBlock($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
#stackZone
|
||||
class="stack-zone"
|
||||
[style.--block-stack-height]="blockStackHeight()"
|
||||
>
|
||||
<img
|
||||
src="assets/plus-sign.svg"
|
||||
class="add-block"
|
||||
alt="Add block"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.enter)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.space)="$event.preventDefault(); $event.stopPropagation(); openAddBlock()"
|
||||
/>
|
||||
|
||||
<div class="block-container-container">
|
||||
<div class="block-container">
|
||||
@for (sq of squares(); track sq.key) {
|
||||
<lt-block
|
||||
[block]="sq.block"
|
||||
[baseColor]="tower().base_color"
|
||||
[hovered]="hoveredBlockId() === sq.block.id"
|
||||
[attr.data-block-id]="sq.block.id"
|
||||
[class.descend]="sq.block._anim === 'descend'"
|
||||
[class.ascend]="sq.block._anim === 'ascend'"
|
||||
[style.transform]="sq.block._transform"
|
||||
[style.opacity]="sq.block._opacity"
|
||||
(pointerenter)="onBlockPointerEnter(sq.block.id)"
|
||||
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
|
||||
(clicked)="onEditBlock(sq.block)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (hiddenBlockCount() > 0) {
|
||||
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editEntry(); as entry) {
|
||||
<lt-modal (close)="closeEdit()">
|
||||
<lt-block-edit
|
||||
[viewTitle]="editViewTitle()"
|
||||
[blocks]="filteredForEntry()"
|
||||
[activeBlockId]="entry.activeId"
|
||||
[tags]="towerTags()"
|
||||
[baseColor]="tower().base_color"
|
||||
[defaultDone]="entry.filter === 'done'"
|
||||
(save)="onBlockSave($event)"
|
||||
(delete)="onBlockDelete($event)"
|
||||
(close)="closeEdit()"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (showSettings()) {
|
||||
<lt-modal (close)="showSettings.set(false)">
|
||||
<lt-tower-settings
|
||||
[tower]="tower()"
|
||||
(save)="onTowerSave($event)"
|
||||
(delete)="onTowerDelete()"
|
||||
(close)="showSettings.set(false)"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
min-height: 0;
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
&.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@media (min-width: $mobile-width) {
|
||||
div.container {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cdk-drag-preview {
|
||||
div.container {
|
||||
@media (max-width: $mobile-width) {
|
||||
@keyframes shadow {
|
||||
from { box-shadow: none; }
|
||||
to { box-shadow: $shadow; }
|
||||
}
|
||||
animation: shadow $long-animation-time forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.trash-highlight {
|
||||
.container {
|
||||
transform: scale(0.75);
|
||||
position: relative;
|
||||
|
||||
:before {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tower-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tower {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
--block-stack-height: 0px;
|
||||
--add-block-size: 48px;
|
||||
--add-block-clearance: var(--medium-padding);
|
||||
--add-block-center-offset: 0px;
|
||||
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
padding: 0;
|
||||
--add-block-size: 32px;
|
||||
--add-block-clearance: var(--small-padding);
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
|
||||
:before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: red;
|
||||
opacity: 0;
|
||||
border-radius: var(--border-radius);
|
||||
transition: opacity $short-animation-time;
|
||||
}
|
||||
|
||||
lt-tasks {
|
||||
flex: 0 1 auto;
|
||||
min-height: 56px;
|
||||
max-height: min(30vh, 45%);
|
||||
overflow: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 44px;
|
||||
max-height: min(25vh, 45%);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.stack-zone {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 48px;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
opacity: 0.33;
|
||||
transition: opacity $long-animation-time;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img.add-block {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 50%;
|
||||
top: max(
|
||||
0px,
|
||||
min(
|
||||
calc(50% - var(--add-block-size) / 2 - var(--add-block-center-offset)),
|
||||
calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
|
||||
)
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-end;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
transform: scaleY(-1);
|
||||
|
||||
/* Default resting position for all blocks before JS sets them */
|
||||
* {
|
||||
transform: translateY(500%);
|
||||
}
|
||||
|
||||
.descend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
|
||||
}
|
||||
|
||||
.ascend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-blocks {
|
||||
@include small-text();
|
||||
position: absolute;
|
||||
top: calc(100% + var(--small-padding));
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
color: rgba($text-color, 0.72);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tower-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
input[type='text'] {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
font-size: var(--small-font-size);
|
||||
text-align: center;
|
||||
|
||||
/* Truncate long titles with an ellipsis instead of wrapping. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Reserve a symmetric gutter on each side. The right one keeps the
|
||||
title and its focus underline clear of the absolutely-positioned
|
||||
pen (so the underline stops before it); the equal left one keeps
|
||||
the centered title optically centered. (pen 22px + 4px gap.) */
|
||||
width: calc(100% - 52px);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: calc(100% - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-tower {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
box-sizing: border-box;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
opacity: 0.35;
|
||||
transition: opacity $short-animation-time, box-shadow $long-animation-time, background-color $short-animation-time;
|
||||
|
||||
/* Suppress the global button's animated hover underline
|
||||
(button::after), which the 'all: unset' reset doesn't reach. */
|
||||
&:after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
background-color: rgba($light-color, 0.86);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||
readonly tower = input.required<Tower>();
|
||||
/** Set by page component when this tower is being dragged over trash. */
|
||||
readonly trashHighlight = input<boolean>(false);
|
||||
/** Optional date range filter — when set, blocks with `created_at`
|
||||
* outside [from, to] are hidden from the falling stack. */
|
||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||
/** When true, the tasks accordion starts expanded on load. */
|
||||
readonly keepTasksOpen = input<boolean>(false);
|
||||
/** When true, completed blocks descend on this tower's first measured render. */
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
|
||||
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||
readonly updateTower = output<TowerSettingsResult>();
|
||||
readonly deleteTowerRequest = output<void>();
|
||||
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
||||
readonly addBlock = output<{ tag: string; description: string; is_done: boolean; difficulty: number }>();
|
||||
/** Emitted when an existing block is patched from the carousel. */
|
||||
readonly saveBlock = output<{
|
||||
blockId: string;
|
||||
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||
}>();
|
||||
readonly deleteBlock = output<string>();
|
||||
|
||||
// ── UI state ───────────────────────────────────────────────────────────────
|
||||
/** The single source of truth for "block-edit modal open" — encodes both
|
||||
* which list of blocks to show and which one to focus initially. */
|
||||
readonly editEntry = signal<EditEntry | null>(null);
|
||||
readonly showSettings = signal(false);
|
||||
readonly hiddenBlockCount = signal(0);
|
||||
readonly hoveredBlockId = signal<string | null>(null);
|
||||
|
||||
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
readonly pending = computed(() => this.tower().blocks.filter((b) => !b.is_done));
|
||||
|
||||
/** CSS color string for the tower name input. */
|
||||
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||
|
||||
/** Filtered list passed to the block-edit carousel. */
|
||||
readonly filteredForEntry = computed(() => {
|
||||
const entry = this.editEntry();
|
||||
if (!entry) return [];
|
||||
const isDone = entry.filter === 'done';
|
||||
return this.tower().blocks.filter((b) => b.is_done === isDone);
|
||||
});
|
||||
|
||||
readonly editViewTitle = computed(() => {
|
||||
const entry = this.editEntry();
|
||||
if (!entry) return '';
|
||||
const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks';
|
||||
return `${prefix} of ${this.tower().name}`;
|
||||
});
|
||||
|
||||
/** Unique tags from existing blocks of this tower. */
|
||||
readonly towerTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const b of this.tower().blocks) if (b.tag) set.add(b.tag);
|
||||
return [...set];
|
||||
});
|
||||
|
||||
// ── Falling animation ──────────────────────────────────────────────────────
|
||||
// Same approach as the legacy: detect "exactly one done block was added"
|
||||
// and snap that last block to translateY(500%)/opacity:0, then on next
|
||||
// tick flip it back to translateY(0)/opacity:1 with .descend so the
|
||||
// 1.5s gravity transition fires.
|
||||
|
||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
||||
|
||||
/** Flat list of squares to render: each visible block expands into
|
||||
* `difficulty` adjacent squares that wrap (via the flex container) into
|
||||
* the row above. All squares of a block share its animation state. */
|
||||
readonly squares = computed<RenderSquare[]>(() => {
|
||||
const out: RenderSquare[] = [];
|
||||
for (const b of this.visibleBlocks()) {
|
||||
const n = squareCount(b);
|
||||
for (let k = 0; k < n; k++) {
|
||||
out.push({ key: `${b.id}#${k}`, block: b });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
readonly blockStackHeight = computed(() => {
|
||||
const rows = Math.ceil(this.squares().length / BLOCKS_PER_ROW);
|
||||
const cqw = rows * (100 / BLOCKS_PER_ROW);
|
||||
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
|
||||
});
|
||||
|
||||
private prevDoneIds: string[] = [];
|
||||
private isFirstRun = true;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const range = this.dateRange();
|
||||
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||
const animateInitialStack = this.animateInitialStack();
|
||||
// Reconcile all done blocks, then cap the rendered stack to the rows
|
||||
// that fit below the tasks and add button.
|
||||
const allDone = this.tower().blocks.filter((b) => b.is_done);
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const stackZone = this.stackZone()?.nativeElement;
|
||||
if (!stackZone) return;
|
||||
|
||||
this.measureBlockCapacity();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => this.measureBlockCapacity());
|
||||
this.resizeObserver.observe(stackZone);
|
||||
}
|
||||
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => this.measureBlockCapacity());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
private measureBlockCapacity(): void {
|
||||
const stackZone = this.stackZone()?.nativeElement;
|
||||
if (!stackZone) return;
|
||||
|
||||
const width = stackZone.clientWidth;
|
||||
const height = stackZone.clientHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
this.maxVisibleBlocks.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(stackZone);
|
||||
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
|
||||
this.measureAddBlockCenterOffset(stackZone);
|
||||
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
|
||||
const clearance = this.parseCssPixels(
|
||||
styles.getPropertyValue('--add-block-clearance'),
|
||||
fallbackClearance,
|
||||
);
|
||||
|
||||
const rowHeight = width / BLOCKS_PER_ROW;
|
||||
const availableHeight = Math.max(0, height - addBlockSize - 2 * clearance);
|
||||
const rows = Math.floor((availableHeight + 0.5) / rowHeight);
|
||||
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
|
||||
}
|
||||
|
||||
private measureAddBlockCenterOffset(stackZone: HTMLElement): void {
|
||||
const towerRoot = this.towerRoot()?.nativeElement;
|
||||
if (!towerRoot) return;
|
||||
|
||||
const stackRect = stackZone.getBoundingClientRect();
|
||||
const towerRect = towerRoot.getBoundingClientRect();
|
||||
const stackCenter = stackRect.top + stackRect.height / 2;
|
||||
const towerCenter = towerRect.top + towerRect.height / 2;
|
||||
const offset = Math.max(0, stackCenter - towerCenter);
|
||||
stackZone.style.setProperty('--add-block-center-offset', `${offset}px`);
|
||||
}
|
||||
|
||||
private parseCssPixels(value: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private reconcile(
|
||||
allDone: Block[],
|
||||
range: { from: number; to: number } | null,
|
||||
maxVisibleBlocks: number | null,
|
||||
animateInitialStack: boolean,
|
||||
): void {
|
||||
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||
this._visibleBlocks.set([]);
|
||||
this.hiddenBlockCount.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = allDone.map((b) => b.id);
|
||||
const prev = this.prevDoneIds;
|
||||
const prevSet = new Set(prev);
|
||||
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||
const grewByOne =
|
||||
!this.isFirstRun &&
|
||||
ids.length === prev.length + 1 &&
|
||||
newIds.length === 1 &&
|
||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||
|
||||
const inRange = (b: Block) =>
|
||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
||||
|
||||
const styled: StyledBlock[] = [];
|
||||
for (const b of allDone) {
|
||||
if (range && b.created_at < range.from) {
|
||||
// Below min-thumb boundary → drop entirely (instant shuffle, no animation).
|
||||
continue;
|
||||
}
|
||||
if (range && b.created_at > range.to) {
|
||||
// Above max-thumb boundary → fly up off the tower with gravity animation.
|
||||
styled.push({
|
||||
...b,
|
||||
_anim: 'ascend',
|
||||
_transform: 'translateY(500%)',
|
||||
_opacity: '0',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// In range — descend into position (or appear instantly on first run).
|
||||
styled.push({
|
||||
...b,
|
||||
_anim: this.isFirstRun ? '' : 'descend',
|
||||
_transform: 'translateY(0)',
|
||||
_opacity: '1',
|
||||
});
|
||||
}
|
||||
|
||||
const newInRangeId = grewByOne ? newIds[0] : null;
|
||||
// Captured before any `_visibleBlocks.set` below — lets the cap keep
|
||||
// currently-shown blocks that are now flying out so their exit animates.
|
||||
const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id));
|
||||
const visibleLimit =
|
||||
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
|
||||
let visibleStyled = styled;
|
||||
let hiddenCount = 0;
|
||||
if (Number.isFinite(visibleLimit)) {
|
||||
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
|
||||
styled,
|
||||
visibleLimit,
|
||||
newInRangeId,
|
||||
prevVisibleIds,
|
||||
));
|
||||
}
|
||||
this.hiddenBlockCount.set(hiddenCount);
|
||||
|
||||
if (this.isFirstRun && animateInitialStack) {
|
||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
|
||||
if (initialBlocks.length > 0) {
|
||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||
if (newBlock && inRange(newBlock)) {
|
||||
// Snap newly-added in-range block to start position, then on the next
|
||||
// paint flip it back to rest — that's what makes it visibly fall.
|
||||
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// existing fall-through path (no growth, first run, or new block out of range):
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
}
|
||||
|
||||
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
block._anim = '';
|
||||
block._transform = 'translateY(500%)';
|
||||
block._opacity = '0';
|
||||
}
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
onRename(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== this.tower().name) {
|
||||
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||
}
|
||||
}
|
||||
|
||||
onEditBlock(block: Block): void {
|
||||
this.hoveredBlockId.set(null);
|
||||
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
|
||||
}
|
||||
|
||||
onBlockPointerEnter(blockId: string): void {
|
||||
this.hoveredBlockId.set(blockId);
|
||||
}
|
||||
|
||||
onBlockPointerLeave(blockId: string, event: PointerEvent): void {
|
||||
if (this.relatedTargetBelongsToBlock(event.relatedTarget, blockId)) return;
|
||||
if (this.hoveredBlockId() === blockId) this.hoveredBlockId.set(null);
|
||||
}
|
||||
|
||||
private relatedTargetBelongsToBlock(target: EventTarget | null, blockId: string): boolean {
|
||||
const towerRoot = this.towerRoot()?.nativeElement;
|
||||
let element = target instanceof Element ? target : null;
|
||||
|
||||
while (element && element !== towerRoot) {
|
||||
if (element instanceof HTMLElement && element.dataset['blockId'] === blockId) return true;
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
|
||||
onMarkTaskDone(block: Block): void {
|
||||
this.saveBlock.emit({
|
||||
blockId: block.id,
|
||||
result: {
|
||||
tag: block.tag,
|
||||
description: block.description,
|
||||
is_done: true,
|
||||
difficulty: block.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Called by the template "Add block" plus-icon. */
|
||||
openAddBlock(): void {
|
||||
this.editEntry.set({ filter: 'done', activeId: null });
|
||||
}
|
||||
|
||||
closeEdit(): void {
|
||||
this.editEntry.set(null);
|
||||
}
|
||||
|
||||
onBlockSave(ev: BlockEditSave): void {
|
||||
if (ev.id === null) {
|
||||
this.addBlock.emit({
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
});
|
||||
} else {
|
||||
this.saveBlock.emit({
|
||||
blockId: ev.id,
|
||||
result: {
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onBlockDelete(id: string): void {
|
||||
// Don't close the carousel — the deleted block disappears from `blocks()`
|
||||
// and the carousel re-renders in place. The user keeps editing siblings.
|
||||
this.deleteBlock.emit(id);
|
||||
}
|
||||
|
||||
onTowerSave(result: TowerSettingsResult): void {
|
||||
// Tower edits auto-save, so this fires on every change and must NOT close
|
||||
// the modal — the user closes it via the exit button / backdrop.
|
||||
this.updateTower.emit(result);
|
||||
}
|
||||
|
||||
onTowerDelete(): void {
|
||||
this.showSettings.set(false);
|
||||
this.deleteTowerRequest.emit();
|
||||
}
|
||||
}
|
||||
129
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StyledBlock } from './tower.component';
|
||||
import { selectVisibleStyledBlocks } from './tower.component';
|
||||
|
||||
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
|
||||
return {
|
||||
id,
|
||||
tag: 'tag',
|
||||
description: id,
|
||||
is_done: true,
|
||||
difficulty,
|
||||
created_at: 1,
|
||||
_anim: '',
|
||||
_transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
|
||||
_opacity: opacity,
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectVisibleStyledBlocks', () => {
|
||||
it('reserves a capped visible slot for the newly completed block', () => {
|
||||
const styled = [
|
||||
block('newly-done'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'newly-done',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the normal capped window when no new block is entering', () => {
|
||||
const styled = [
|
||||
block('done-0'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'done-2',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps by square cost (difficulty), not by raw block count', () => {
|
||||
// Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
|
||||
const hard = (id: string): StyledBlock => block(id, '1', 2);
|
||||
const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
|
||||
expect(result.hiddenCount).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps a previously-visible block that is now flying out, even on a full stack', () => {
|
||||
// Budget is full with three resting blocks; a fourth block has just left the
|
||||
// range (opacity 0 → ascending). It was visible a moment ago, so it must stay
|
||||
// rendered so its fly-up transition can play instead of vanishing instantly.
|
||||
const styled = [
|
||||
block('old-1'),
|
||||
block('old-2'),
|
||||
block('old-3'),
|
||||
block('leaving', '0'),
|
||||
];
|
||||
const prevVisible = new Set(['old-1', 'old-2', 'old-3', 'leaving']);
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
|
||||
|
||||
expect(result.visibleStyled.map((b) => b.id)).toContain('leaving');
|
||||
expect(result.hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not resurrect an off-screen block that leaves the range', () => {
|
||||
// 'hidden-leaving' was never rendered (not in prevVisible) — nothing to
|
||||
// animate from, so keep it out and avoid an unbounded phantom render set.
|
||||
const styled = [
|
||||
block('old-1'),
|
||||
block('old-2'),
|
||||
block('old-3'),
|
||||
block('hidden-leaving', '0'),
|
||||
];
|
||||
const prevVisible = new Set(['old-1', 'old-2', 'old-3']);
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
|
||||
|
||||
expect(result.visibleStyled.map((b) => b.id)).not.toContain('hidden-leaving');
|
||||
});
|
||||
|
||||
it('counts hidden squares when reserving the entering block', () => {
|
||||
const styled = [
|
||||
block('newly-done', '1', 3),
|
||||
block('done-1', '1', 2),
|
||||
block('done-2', '1', 2),
|
||||
block('done-3', '1', 2),
|
||||
block('done-4', '1', 2),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(6);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
|
||||
});
|
||||
});
|
||||
387
frontend/src/app/components/welcome/welcome.component.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-welcome',
|
||||
standalone: true,
|
||||
imports: [A11yModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="welcome-card">
|
||||
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
|
||||
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
aria-label="Dismiss welcome"
|
||||
></button>
|
||||
|
||||
<p class="lead" id="welcome-description">
|
||||
Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
|
||||
home, or any project. Add towers for task groups, then check off tasks to build them
|
||||
block by block.
|
||||
</p>
|
||||
|
||||
<p class="sr-only">
|
||||
Preview showing three towers with pending task bars at the top and completed task
|
||||
blocks stacked below.
|
||||
</p>
|
||||
<div class="tower-preview" aria-hidden="true">
|
||||
<div class="preview-shell">
|
||||
<div class="preview-page-tab"></div>
|
||||
<div class="preview-towers">
|
||||
<div class="preview-tower preview-tower--reading">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--projects">
|
||||
<span class="preview-task"></span>
|
||||
<span class="preview-task preview-task--short"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--exercise">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sr-only" id="welcome-basics-title">How Life Towers works</h3>
|
||||
<dl class="basics" aria-labelledby="welcome-basics-title">
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Pages</dt>
|
||||
<dd class="basic__text">Keep each area separate.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Towers</dt>
|
||||
<dd class="basic__text">Stack related tasks together.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Blocks</dt>
|
||||
<dd class="basic__text">Finished tasks become colored blocks.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="startFresh.emit()">Start empty</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host { display: block; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
@include card();
|
||||
width: min(560px, calc(100vw - (2 * var(--large-padding))));
|
||||
max-width: 560px;
|
||||
max-height: calc(100svh - (2 * var(--medium-padding)));
|
||||
overflow-y: auto;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: min(88vw, calc(100vw - (2 * var(--medium-padding))));
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
text-align: left;
|
||||
font-family: $normal-font;
|
||||
font-weight: 300;
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.45;
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: $title-font;
|
||||
font-weight: 400;
|
||||
font-size: var(--larger-font-size);
|
||||
text-align: center;
|
||||
padding: 0 36px;
|
||||
line-height: 1.25;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: $text-color;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
max-width: 46ch;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tower-preview {
|
||||
box-sizing: border-box;
|
||||
padding: var(--medium-padding) 0;
|
||||
border-top: 1px solid rgba($text-color, 0.08);
|
||||
border-bottom: 1px solid rgba($text-color, 0.08);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding-block: var(--small-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
width: min(100%, 370px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-page-tab {
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
margin: 0 auto var(--small-padding);
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.08);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
.preview-towers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.preview-tower {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 128px;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.025);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
.preview-task {
|
||||
display: block;
|
||||
width: 74%;
|
||||
height: 5px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--task-color);
|
||||
}
|
||||
|
||||
.preview-task--short {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.preview-stack {
|
||||
display: flex;
|
||||
flex-flow: row wrap-reverse;
|
||||
align-content: flex-start;
|
||||
gap: 2px;
|
||||
min-height: 86px;
|
||||
margin-top: auto;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
flex: 0 0 calc((100% - 4px) / 3);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--block-color);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
span:nth-child(2n) {
|
||||
filter: saturate(0.9) brightness(1.06);
|
||||
}
|
||||
|
||||
span:nth-child(3n) {
|
||||
filter: saturate(1.08) brightness(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes preview-task-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.42;
|
||||
transform: scaleX(0.86);
|
||||
}
|
||||
45%, 70% {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-block-fall {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-220%);
|
||||
}
|
||||
60%, 100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-task {
|
||||
transform-origin: left center;
|
||||
animation: preview-task-pulse 2600ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.preview-stack span {
|
||||
opacity: 0;
|
||||
animation: preview-block-fall 1200ms cubic-bezier(0.5, 0, 1, 0) forwards;
|
||||
animation-delay: calc(160ms + var(--fall-index, 0) * 95ms);
|
||||
}
|
||||
|
||||
.preview-stack span:nth-child(1) { --fall-index: 0; }
|
||||
.preview-stack span:nth-child(2) { --fall-index: 1; }
|
||||
.preview-stack span:nth-child(3) { --fall-index: 2; }
|
||||
.preview-stack span:nth-child(4) { --fall-index: 3; }
|
||||
.preview-stack span:nth-child(5) { --fall-index: 4; }
|
||||
.preview-stack span:nth-child(6) { --fall-index: 5; }
|
||||
.preview-stack span:nth-child(7) { --fall-index: 6; }
|
||||
.preview-stack span:nth-child(8) { --fall-index: 7; }
|
||||
.preview-stack span:nth-child(9) { --fall-index: 8; }
|
||||
}
|
||||
|
||||
.preview-tower--reading {
|
||||
--block-color: hsl(18, 70%, 58%);
|
||||
--task-color: hsla(18, 70%, 58%, 0.26);
|
||||
}
|
||||
|
||||
.preview-tower--projects {
|
||||
--block-color: hsl(209, 65%, 52%);
|
||||
--task-color: hsla(209, 65%, 52%, 0.24);
|
||||
}
|
||||
|
||||
.preview-tower--exercise {
|
||||
--block-color: hsl(130, 45%, 44%);
|
||||
--task-color: hsla(130, 45%, 44%, 0.22);
|
||||
}
|
||||
|
||||
.basics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--small-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.basic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.basic__label {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: $text-color;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.basic__text {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: rgba($text-color, 0.82);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--large-padding);
|
||||
margin-top: var(--large-padding);
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
line-height: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: $accent-color;
|
||||
border-bottom-color: rgba($accent-color, 0.33);
|
||||
&:after { background-color: $accent-color; }
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class WelcomeComponent {
|
||||
readonly close = output<void>();
|
||||
readonly startFresh = output<void>();
|
||||
readonly loadExample = output<void>();
|
||||
}
|
||||
46
frontend/src/app/models/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export interface HslColor {
|
||||
h: number; // 0-1
|
||||
s: number; // 0-1
|
||||
l: number; // 0-1
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
/** How many squares this block draws in the tower (>= 1). */
|
||||
difficulty: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Tower {
|
||||
id: string;
|
||||
name: string;
|
||||
base_color: HslColor;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
default_date_from: number | null;
|
||||
default_date_to: number | null;
|
||||
towers: Tower[];
|
||||
}
|
||||
|
||||
export interface TreeDto {
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
export type SaveStatus =
|
||||
| 'idle'
|
||||
| 'saving'
|
||||
| 'saved'
|
||||
| 'retrying'
|
||||
| 'error' // generic / network — will keep trying
|
||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
||||
| 'rate-limited' // 429 — will retry after Retry-After
|
||||
| 'invalid'; // 400 — server rejected the body, won't retry
|
||||
65
frontend/src/app/services/analytics.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable, isDevMode } from '@angular/core';
|
||||
import {
|
||||
init as plausibleInit,
|
||||
track as plausibleTrack,
|
||||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'schmelczer.dev/towers';
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsService {
|
||||
private isInitialized = false;
|
||||
private hasTrackedStart = false;
|
||||
|
||||
init(): void {
|
||||
if (this.isInitialized) return;
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: isDevMode(),
|
||||
});
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.warn('Could not initialize analytics.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private track(eventName: string, options: PlausibleEventOptions = {}): void {
|
||||
try {
|
||||
plausibleTrack(eventName, options);
|
||||
} catch (error) {
|
||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
||||
}
|
||||
}
|
||||
|
||||
trackStart(): void {
|
||||
if (this.hasTrackedStart) return;
|
||||
this.hasTrackedStart = true;
|
||||
this.track('Start');
|
||||
}
|
||||
|
||||
trackExampleLoaded(): void {
|
||||
this.track('Example Loaded');
|
||||
}
|
||||
|
||||
trackPageCreated(): void {
|
||||
this.track('Page Created');
|
||||
}
|
||||
|
||||
trackTowerCreated(): void {
|
||||
this.track('Tower Created');
|
||||
}
|
||||
|
||||
trackBlockCreated({ isDone }: { isDone: boolean }): void {
|
||||
this.track('Block Created', { props: { isDone: String(isDone) } });
|
||||
}
|
||||
|
||||
trackBlockCompleted(): void {
|
||||
this.track('Block Completed');
|
||||
}
|
||||
}
|
||||
35
frontend/src/app/services/api.service.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TreeDto } from '../models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
health(): Promise<{ status: string }> {
|
||||
return firstValueFrom(this.http.get<{ status: string }>('/api/v1/health'));
|
||||
}
|
||||
|
||||
register(token: string): Promise<{ user_id: string }> {
|
||||
return firstValueFrom(
|
||||
this.http.post<{ user_id: string }>('/api/v1/register', { token }),
|
||||
);
|
||||
}
|
||||
|
||||
getData(token: string): Promise<TreeDto> {
|
||||
return firstValueFrom(
|
||||
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
putData(token: string, tree: TreeDto): Promise<void> {
|
||||
return firstValueFrom(
|
||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
private authHeaders(token: string): HttpHeaders {
|
||||
return new HttpHeaders({ Authorization: `Bearer ${token}` });
|
||||
}
|
||||
}
|
||||
28
frontend/src/app/services/api.service.vitest.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock environment
|
||||
vi.mock('../../environments/environment', () => ({
|
||||
environment: { apiBase: 'http://test-api', production: false },
|
||||
}));
|
||||
|
||||
describe('ApiService URL patterns', () => {
|
||||
const baseUrl = 'http://test-api';
|
||||
|
||||
it('constructs correct health URL', () => {
|
||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
||||
});
|
||||
|
||||
it('constructs correct register URL', () => {
|
||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
||||
});
|
||||
|
||||
it('constructs correct data URL', () => {
|
||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
||||
});
|
||||
|
||||
it('formats Authorization header correctly', () => {
|
||||
const token = 'abc-def-123';
|
||||
const header = `Bearer ${token}`;
|
||||
expect(header).toBe('Bearer abc-def-123');
|
||||
});
|
||||
});
|
||||
25
frontend/src/app/services/modal-state.service.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Shared counter of currently-open modals. Each `lt-modal` increments on
|
||||
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||
*
|
||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
||||
* (block-edit carousel, page-settings, tower-settings, confirm-delete,
|
||||
* settings) is on screen — otherwise the user can drag towers from behind
|
||||
* the open card.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ModalStateService {
|
||||
private readonly _openCount = signal(0);
|
||||
|
||||
readonly anyOpen = computed(() => this._openCount() > 0);
|
||||
|
||||
open(): void {
|
||||
this._openCount.update((n) => n + 1);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._openCount.update((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
712
frontend/src/app/services/store.service.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const DEBOUNCE_MS = 750;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and
|
||||
// falls back to crypto.getRandomValues — which works on plain http origins
|
||||
// behind a non-localhost reverse proxy too.
|
||||
function uuidV4(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function isUuidV4(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
// localStorage can throw (private mode, quota exceeded, disabled). All
|
||||
// access goes through these helpers so a transient failure never bubbles
|
||||
// up and breaks app init.
|
||||
function safeGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function safeSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingPut {
|
||||
token: string;
|
||||
tree: TreeDto;
|
||||
}
|
||||
|
||||
interface ExampleBlockSeed {
|
||||
tag: string;
|
||||
desc: string;
|
||||
done: boolean;
|
||||
difficulty?: number;
|
||||
ageHrs: number;
|
||||
}
|
||||
|
||||
interface ExampleDonePattern {
|
||||
tag: string;
|
||||
desc: (sequence: number) => string;
|
||||
}
|
||||
|
||||
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 12;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StoreService implements OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
private readonly _pages = signal<Page[]>([]);
|
||||
private readonly _saveStatus = signal<SaveStatus>('idle');
|
||||
private readonly _token = signal<string>('');
|
||||
private readonly _loading = signal<boolean>(true);
|
||||
|
||||
readonly pages = this._pages.asReadonly();
|
||||
readonly saveStatus = this._saveStatus.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly token = this._token.asReadonly();
|
||||
|
||||
readonly pageCount = computed(() => this._pages().length);
|
||||
|
||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushInFlight = false;
|
||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||
private dirtyDuringFlush = false;
|
||||
|
||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||
private readonly storageListener = (e: StorageEvent) => {
|
||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||
// Another tab switched accounts. Re-init with the new token instead of
|
||||
// continuing to sync the old account's state to the new one.
|
||||
this._token.set(e.newValue);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this.cancelPendingWrites();
|
||||
void this.init();
|
||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
||||
// (to avoid clobbering our own state with the other tab's older view).
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(e.newValue);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.doInit().finally(() => {
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
// (idempotent — adding the same listener twice is a no-op)
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
}
|
||||
|
||||
let stored = safeGet(TOKEN_KEY);
|
||||
if (stored && !isUuidV4(stored)) {
|
||||
// Garbage in localStorage from a buggy past version — refuse it.
|
||||
stored = null;
|
||||
}
|
||||
const token = stored ?? uuidV4();
|
||||
if (!stored) {
|
||||
safeSet(TOKEN_KEY, token);
|
||||
}
|
||||
this._token.set(token);
|
||||
|
||||
if (!stored) {
|
||||
try {
|
||||
await this.api.register(token);
|
||||
} catch {
|
||||
// Non-fatal; the 401 path below will re-attempt registration.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
// Token unknown to server — re-register (idempotent) and retry.
|
||||
try {
|
||||
await this.api.register(token);
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} else {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a freshly-fetched server tree. If the server is empty but our local
|
||||
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||
* "server forgot me" recovery would silently wipe offline edits.
|
||||
*/
|
||||
private adoptServerTree(tree: TreeDto): void {
|
||||
if (tree.pages.length === 0) {
|
||||
const cached = safeGet(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const cachedTree: TreeDto = JSON.parse(cached);
|
||||
if (cachedTree.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to server-empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
this._pages.set(tree.pages);
|
||||
this.updateCache(tree);
|
||||
}
|
||||
|
||||
private loadFromCache(): void {
|
||||
const raw = safeGet(CACHE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(raw);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCache(tree: TreeDto): void {
|
||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
||||
}
|
||||
|
||||
private cancelPendingWrites(): void {
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.dirtyDuringFlush = false;
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
addPage(name: string): void {
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackPageCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updatePage(id: string, patch: Partial<Omit<Page, 'id' | 'towers'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deletePage(id: string): void {
|
||||
this._pages.update((pages) => pages.filter((p) => p.id !== id));
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderPages(fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) => {
|
||||
const arr = [...pages];
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
});
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackTowerCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? { ...p, towers: p.towers.map((t) => (t.id === towerId ? { ...t, ...patch } : t)) }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteTower(pageId: string, towerId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId ? { ...p, towers: p.towers.filter((t) => t.id !== towerId) } : p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => {
|
||||
if (p.id !== pageId) return p;
|
||||
const towers = [...p.towers];
|
||||
const [item] = towers.splice(fromIndex, 1);
|
||||
towers.splice(toIndex, 0, item);
|
||||
return { ...p, towers };
|
||||
}),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
tag: string,
|
||||
description: string,
|
||||
is_done = false,
|
||||
difficulty = 1,
|
||||
): void {
|
||||
const block: Block = {
|
||||
id: uuidV4(),
|
||||
tag,
|
||||
description,
|
||||
is_done,
|
||||
difficulty,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackBlockCreated({ isDone: is_done });
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
let becameDone = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? (() => {
|
||||
const block = t.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return t;
|
||||
const result = this.patchBlockList(t.blocks, blockId, {
|
||||
is_done: !block.is_done,
|
||||
});
|
||||
becameDone = result.becameDone;
|
||||
return { ...t, blocks: result.blocks };
|
||||
})()
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
if (becameDone) this.analytics.trackBlockCompleted();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private patchBlockList(
|
||||
blocks: Block[],
|
||||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): { blocks: Block[]; becameDone: boolean } {
|
||||
const nextBlocks: Block[] = [];
|
||||
let completedBlock: Block | null = null;
|
||||
let becameDone = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.id !== blockId) {
|
||||
nextBlocks.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated: Block = { ...block, ...patch };
|
||||
becameDone = !block.is_done && updated.is_done;
|
||||
if (becameDone) {
|
||||
// Display newly completed todos as the newest square, without changing
|
||||
// created_at because the date slider still filters on creation time.
|
||||
completedBlock = updated;
|
||||
} else {
|
||||
nextBlocks.push(updated);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks,
|
||||
becameDone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different user's token. Any pending writes for the OLD
|
||||
* account must be cancelled first — otherwise a queued PUT could fire
|
||||
* against the NEW account with the old account's data (or vice versa,
|
||||
* if the timing flipped).
|
||||
*/
|
||||
switchToken(newToken: string): void {
|
||||
if (!isUuidV4(newToken)) return;
|
||||
this.cancelPendingWrites();
|
||||
safeSet(TOKEN_KEY, newToken);
|
||||
this._token.set(newToken);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this._saveStatus.set('idle');
|
||||
void this.init();
|
||||
}
|
||||
|
||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.flushInFlight) {
|
||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.runFlush();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* One save attempt with bounded retries. Captures the token and tree
|
||||
* snapshot up front so a mid-flight switchToken can't redirect this
|
||||
* write to a different account.
|
||||
*/
|
||||
private async runFlush(): Promise<void> {
|
||||
if (this.flushInFlight) {
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
const token = this._token();
|
||||
if (!token) return;
|
||||
|
||||
this.flushInFlight = true;
|
||||
this.dirtyDuringFlush = false;
|
||||
|
||||
// Cancel any pending retry — runFlush() supersedes it.
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
||||
} finally {
|
||||
this.flushInFlight = false;
|
||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||
if (this.dirtyDuringFlush) {
|
||||
this.dirtyDuringFlush = false;
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attempt(put: PendingPut, attempt: number): Promise<void> {
|
||||
this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying');
|
||||
try {
|
||||
await this.api.putData(put.token, put.tree);
|
||||
this._saveStatus.set('saved');
|
||||
this.updateCache(put.tree);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const headers = (err as { headers?: { get(name: string): string | null } })?.headers;
|
||||
|
||||
// Permanent failures: no point retrying.
|
||||
if (status === 400) {
|
||||
this._saveStatus.set('invalid');
|
||||
return;
|
||||
}
|
||||
if (status === 413) {
|
||||
this._saveStatus.set('too-large');
|
||||
return;
|
||||
}
|
||||
|
||||
// 401 mid-PUT: server forgot us. Re-register (idempotent) and retry.
|
||||
if (status === 401) {
|
||||
try {
|
||||
await this.api.register(put.token);
|
||||
} catch {
|
||||
// fall through to retry/backoff
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
this._saveStatus.set('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Honor server's Retry-After when present (429 in particular).
|
||||
let delayMs = Math.min(1000 * 2 ** attempt, 30000);
|
||||
if (status === 429) {
|
||||
this._saveStatus.set('rate-limited');
|
||||
const ra = headers?.get('Retry-After') ?? headers?.get('retry-after');
|
||||
if (ra) {
|
||||
const seconds = parseInt(ra, 10);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
delayMs = Math.min(seconds * 1000, 60_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
// The token may have changed during the wait — re-check before retrying.
|
||||
if (this._token() !== put.token) return;
|
||||
// Re-snapshot the latest pages so the retry pushes current state.
|
||||
await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Example data ──────────────────────────────────────────────────────────
|
||||
|
||||
loadExample(): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name: 'Hobbies',
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: true,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
// Done blocks are listed oldest-first so they fill the falling stack
|
||||
// oldest → newest (left → right), matching the slider's old → new
|
||||
// labels; pending tasks stay on top for the accordion.
|
||||
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
|
||||
{
|
||||
tag: 'novel',
|
||||
desc: 'Finish The Brothers Karamazov',
|
||||
done: false,
|
||||
difficulty: 4,
|
||||
ageHrs: 0,
|
||||
},
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'novel', desc: (n) => `Read chapter ${n}` },
|
||||
{ tag: 'paper', desc: (n) => `Annotated paper ${n}` },
|
||||
{ tag: 'article', desc: (n) => `Saved article notes ${n}` },
|
||||
{ tag: 'essay', desc: (n) => `Drafted reading response ${n}` },
|
||||
],
|
||||
2,
|
||||
8,
|
||||
),
|
||||
]),
|
||||
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
|
||||
{
|
||||
tag: 'angular',
|
||||
desc: 'Modernise the towers app',
|
||||
done: false,
|
||||
difficulty: 3,
|
||||
ageHrs: 0,
|
||||
},
|
||||
{
|
||||
tag: 'rust',
|
||||
desc: 'Port the sync layer to Tauri',
|
||||
done: false,
|
||||
difficulty: 5,
|
||||
ageHrs: 1,
|
||||
},
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'angular', desc: (n) => `Refined UI pass ${n}` },
|
||||
{ tag: 'rust', desc: (n) => `Completed systems spike ${n}` },
|
||||
{ tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` },
|
||||
{ tag: 'docs', desc: (n) => `Captured project note ${n}` },
|
||||
],
|
||||
4,
|
||||
9,
|
||||
),
|
||||
]),
|
||||
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
|
||||
{ tag: 'run', desc: '10k Sunday', done: false, difficulty: 2, ageHrs: 0 },
|
||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, difficulty: 4, ageHrs: 4 },
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'run', desc: (n) => `Easy run ${n}` },
|
||||
{ tag: 'climb', desc: (n) => `Bouldering session ${n}` },
|
||||
{ tag: 'mobility', desc: (n) => `Mobility circuit ${n}` },
|
||||
{ tag: 'strength', desc: (n) => `Strength session ${n}` },
|
||||
],
|
||||
6,
|
||||
11,
|
||||
),
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackExampleLoaded();
|
||||
this.scheduleSave();
|
||||
return page.id;
|
||||
}
|
||||
|
||||
private makeExampleTower(
|
||||
name: string,
|
||||
base_color: HslColor,
|
||||
nowSec: number,
|
||||
blocks: ExampleBlockSeed[],
|
||||
): Tower {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
base_color,
|
||||
blocks: blocks.map((b) => ({
|
||||
id: uuidV4(),
|
||||
tag: b.tag,
|
||||
description: b.desc,
|
||||
is_done: b.done,
|
||||
difficulty: b.difficulty ?? 1,
|
||||
created_at: nowSec - Math.floor(b.ageHrs * 3600),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private makeExampleDoneBlocks(
|
||||
patterns: ExampleDonePattern[],
|
||||
newestAgeHrs: number,
|
||||
spacingHrs: number,
|
||||
): ExampleBlockSeed[] {
|
||||
return Array.from({ length: EXAMPLE_DONE_BLOCKS_PER_TOWER }, (_, i) => {
|
||||
const pattern = patterns[i % patterns.length];
|
||||
const sequence = Math.floor(i / patterns.length) + 1;
|
||||
return {
|
||||
tag: pattern.tag,
|
||||
desc: pattern.desc(sequence),
|
||||
done: true,
|
||||
difficulty: 1 + ((i + (i % patterns.length) * 2) % 5),
|
||||
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingWrites();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', this.storageListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
481
frontend/src/app/services/store.service.vitest.ts
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { StoreService } from './store.service';
|
||||
import { ApiService } from './api.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||
const storage: Record<string, string> = {};
|
||||
let storageThrowsOnSet = false;
|
||||
const localStorageStub = {
|
||||
getItem: (k: string) => storage[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
if (storageThrowsOnSet) throw new Error('QuotaExceededError');
|
||||
storage[k] = v;
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete storage[k];
|
||||
},
|
||||
clear: () => Object.keys(storage).forEach((k) => delete storage[k]),
|
||||
key: (i: number) => Object.keys(storage)[i] ?? null,
|
||||
get length() {
|
||||
return Object.keys(storage).length;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageStub,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── crypto stub for tests that need deterministic UUIDs ─────────────────────
|
||||
const realCrypto = globalThis.crypto;
|
||||
function withFixedUuid<T>(uuid: string, fn: () => T): T {
|
||||
const stub = {
|
||||
randomUUID: () => uuid,
|
||||
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'crypto', { value: stub, configurable: true });
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock ApiService factory ──────────────────────────────────────────────────
|
||||
interface MockApi {
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
getData: ReturnType<typeof vi.fn>;
|
||||
putData: ReturnType<typeof vi.fn>;
|
||||
health: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeMockApi(): MockApi {
|
||||
return {
|
||||
health: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
|
||||
getData: vi.fn().mockResolvedValue({ pages: [] } satisfies TreeDto),
|
||||
putData: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function configure(api: MockApi): StoreService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: ApiService, useValue: api },
|
||||
StoreService,
|
||||
],
|
||||
});
|
||||
return TestBed.inject(StoreService);
|
||||
}
|
||||
|
||||
function mkPage(name: string): TreeDto['pages'][number] {
|
||||
return {
|
||||
id: FIXED_UUID,
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// HttpErrorResponse-compatible shape for rejected promises.
|
||||
function httpError(status: number, headers: Record<string, string> = {}) {
|
||||
const headersObj = {
|
||||
get: (n: string) =>
|
||||
headers[n] ?? headers[n.toLowerCase()] ?? headers[n.toUpperCase()] ?? null,
|
||||
};
|
||||
const err: { status: number; headers: typeof headersObj } = { status, headers: headersObj };
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('StoreService', () => {
|
||||
beforeEach(() => {
|
||||
localStorageStub.clear();
|
||||
storageThrowsOnSet = false;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('mints + persists a UUIDv4 token and calls register on first launch', async () => {
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an existing stored token without re-registering', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('on 401 from getData, re-registers the SAME token (idempotent) and retries', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.getData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce({ pages: [mkPage('after-401')] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledTimes(2);
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('after-401');
|
||||
});
|
||||
|
||||
it('falls back to cache on non-401 network error', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('cached')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockRejectedValue(httpError(0));
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('cached');
|
||||
});
|
||||
|
||||
it('keeps local cache when server returns empty but cache has data', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('offline-edit')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('offline-edit');
|
||||
});
|
||||
|
||||
it('init() doesn\'t crash if localStorage.setItem throws (private mode)', async () => {
|
||||
storageThrowsOnSet = true;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await expect(store.init()).resolves.toBeUndefined();
|
||||
});
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('init() is single-flight — concurrent calls return the same promise', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveGet: ((v: TreeDto) => void) | null = null;
|
||||
api.getData.mockReturnValue(new Promise<TreeDto>((res) => (resolveGet = res)));
|
||||
const store = configure(api);
|
||||
|
||||
const p1 = store.init();
|
||||
const p2 = store.init();
|
||||
resolveGet!({ pages: [] });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(api.getData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('moves a pending block to the end when it becomes done', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.getData.mockResolvedValue({
|
||||
pages: [
|
||||
{
|
||||
id: 'page-1',
|
||||
name: 'Page',
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
{
|
||||
id: 'tower-1',
|
||||
name: 'Tower',
|
||||
base_color: { h: 0.5, s: 0.5, l: 0.5 },
|
||||
blocks: [
|
||||
{
|
||||
id: 'old-pending',
|
||||
tag: 'a',
|
||||
description: 'Created first, completed last',
|
||||
is_done: false,
|
||||
difficulty: 1,
|
||||
created_at: 100,
|
||||
},
|
||||
{
|
||||
id: 'newer-pending',
|
||||
tag: 'b',
|
||||
description: 'Still pending',
|
||||
is_done: false,
|
||||
difficulty: 1,
|
||||
created_at: 300,
|
||||
},
|
||||
{
|
||||
id: 'existing-done',
|
||||
tag: 'c',
|
||||
description: 'Already done',
|
||||
is_done: true,
|
||||
difficulty: 1,
|
||||
created_at: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies TreeDto);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.updateBlock('page-1', 'tower-1', 'old-pending', { is_done: true });
|
||||
|
||||
const blocks = store.pages()[0].towers[0].blocks;
|
||||
expect(blocks.map((b) => b.id)).toEqual([
|
||||
'newer-pending',
|
||||
'existing-done',
|
||||
'old-pending',
|
||||
]);
|
||||
expect(blocks[2].created_at).toBe(100);
|
||||
});
|
||||
|
||||
it('loads welcome example data with a stack of completed squares per tower', () => {
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
const pageId = store.loadExample();
|
||||
|
||||
const [page] = store.pages();
|
||||
expect(page.id).toBe(pageId);
|
||||
expect(page.name).toBe('Hobbies');
|
||||
expect(page.towers).toHaveLength(3);
|
||||
|
||||
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
|
||||
expect(doneBlocks.length).toBeGreaterThanOrEqual(90);
|
||||
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
|
||||
.toBeGreaterThan(1);
|
||||
|
||||
for (const tower of page.towers) {
|
||||
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
|
||||
expect(doneDates.length).toBeGreaterThanOrEqual(30);
|
||||
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
|
||||
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
|
||||
}
|
||||
|
||||
store.ngOnDestroy();
|
||||
});
|
||||
|
||||
// ── Debounced save ─────────────────────────────────────────────────────────
|
||||
|
||||
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('A');
|
||||
store.addPage('B');
|
||||
store.addPage('C');
|
||||
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
const [, tree] = api.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('mutation while a save is in-flight triggers a follow-up save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstPut: (() => void) | null = null;
|
||||
api.putData
|
||||
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('first');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Mutate while first PUT is still hanging.
|
||||
store.addPage('second');
|
||||
|
||||
// Finish the first save → follow-up should be scheduled.
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
const lastTree = api.putData.mock.calls[1][1] as TreeDto;
|
||||
expect(lastTree.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(413));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('big');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('too-large');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks status "invalid" on 400 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(400));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('bad');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('invalid');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors Retry-After on 429 (uses it as the next delay)', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '2' }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
// First attempt failed with 429 — we're now in the Retry-After window.
|
||||
expect(store.saveStatus()).toBe('rate-limited');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 1.9s → still waiting (Retry-After was 2s).
|
||||
await vi.advanceTimersByTimeAsync(1900);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full 2s → retry fires and succeeds.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
it('re-registers and retries on 401 mid-save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
// ── switchToken ───────────────────────────────────────────────────────────
|
||||
|
||||
it('switchToken cancels pending writes and does not flush old tree to new account', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
// Mutate, then switch BEFORE the debounce fires.
|
||||
store.addPage('old-account');
|
||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(newToken);
|
||||
|
||||
// Run all timers — the OLD debounce must have been cancelled,
|
||||
// so no PUT should have happened.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(newToken);
|
||||
});
|
||||
|
||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
store.switchToken('not-a-uuid');
|
||||
expect(store.token()).toBe(''); // never initialized
|
||||
});
|
||||
|
||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||
|
||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
expect(store.pages()).toHaveLength(0);
|
||||
|
||||
const otherTabTree = { pages: [mkPage('from-other-tab')] };
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: CACHE_KEY,
|
||||
newValue: JSON.stringify(otherTabTree),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('from-other-tab');
|
||||
});
|
||||
});
|
||||
106
frontend/src/app/styles/_form-shared.scss
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-ui);
|
||||
transition: background 0.15s;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: transparent;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #e53e3e;
|
||||
margin-right: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(229, 62, 62, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/utils/color.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { HslColor } from '../models';
|
||||
import { hash } from './hash';
|
||||
|
||||
/**
|
||||
* Lighten (or darken) an HslColor by a number of percentage points.
|
||||
* `byPercentPoints` is in raw percent (e.g. 25 means +25 lightness points out of 100).
|
||||
* Clamps the result to [0, 1].
|
||||
*/
|
||||
export function lighten(byPercentPoints: number, c: HslColor): HslColor {
|
||||
let newL = c.l * 100 + byPercentPoints;
|
||||
if (newL > 100) newL = 100;
|
||||
else if (newL < 0) newL = 0;
|
||||
return { h: c.h, s: c.s, l: newL / 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HslColor (all values 0–1) to a CSS hsl() string.
|
||||
* Note: the new app stores h/s/l normalised to [0, 1].
|
||||
*/
|
||||
export function toCss(c: HslColor): string {
|
||||
return `hsl(${c.h * 360}, ${c.s * 100}%, ${c.l * 100}%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a per-tag color by offsetting the tower's base lightness deterministically.
|
||||
* Uses FNV-1a hash → offset in [−25, +25) lightness percentage points.
|
||||
* All blocks in the same tower vary in lightness only, preserving the hue and saturation.
|
||||
*/
|
||||
export function getColorOfTag(tag: string, base: HslColor): string {
|
||||
const offset = (hash(tag) - 0.5) * 50; // → [−25, +25) percentage points
|
||||
return toCss(lighten(offset, base));
|
||||
}
|
||||
20
frontend/src/app/utils/hash.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Deterministic hash of a string returning a value in [0, 1).
|
||||
*
|
||||
* Ports the legacy hash.ts exactly:
|
||||
* h = ((h << 5) - h + charCode) | 0, seed = 7
|
||||
* result = h / (2^32 - 2) + 0.5
|
||||
*
|
||||
* The legacy formula keeps the same distribution as the original Angular 7
|
||||
* reference so per-tag block colours are stable across the port.
|
||||
*/
|
||||
export function hash(s: string): number {
|
||||
if (!s) return 0;
|
||||
let h = 7;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
// Same bit-ops as legacy: (h << 5) - h == h * 31, truncated to int32
|
||||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
// Map the signed int32 to [0, 1) — same formula as legacy
|
||||
return h / (Math.pow(2, 32) - 2) + 0.5;
|
||||
}
|
||||
4
frontend/src/assets/arrow.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 292.362 292.362" style="enable-background:new 0 0 292.362 292.362;" xml:space="preserve" class=""><g><g>
|
||||
<path d="M286.935,69.377c-3.614-3.617-7.898-5.424-12.848-5.424H18.274c-4.952,0-9.233,1.807-12.85,5.424 C1.807,72.998,0,77.279,0,82.228c0,4.948,1.807,9.229,5.424,12.847l127.907,127.907c3.621,3.617,7.902,5.428,12.85,5.428 s9.233-1.811,12.847-5.428L286.935,95.074c3.613-3.617,5.427-7.898,5.427-12.847C292.362,77.279,290.548,72.998,286.935,69.377z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
After Width: | Height: | Size: 746 B |