Compare commits
9 commits
40e2c478fb
...
0ba28b0a14
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ba28b0a14 | |||
| 5bf8e752e7 | |||
| ad7968dadd | |||
| 3930982bd8 | |||
| 617e3ef16d | |||
| 5ac6633d40 | |||
| f87480e79d | |||
| 003f38ea60 | |||
| e2a60e71a3 |
|
|
@ -8,77 +8,150 @@ on:
|
|||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Sync dependencies
|
||||
working-directory: backend
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
- name: Run pytest
|
||||
run: uv run pytest -v
|
||||
|
||||
frontend:
|
||||
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: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint (if configured)
|
||||
working-directory: frontend
|
||||
run: |
|
||||
if [ -f eslint.config.js ] || [ -f .eslintrc.json ] || [ -f .eslintrc.js ]; then
|
||||
npm run lint
|
||||
else
|
||||
echo "No ESLint config found, skipping lint"
|
||||
fi
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
docker:
|
||||
frontend-test:
|
||||
name: Frontend unit tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t life-towers:${{ github.sha }} .
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Push to registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
env:
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
- 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: |
|
||||
if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then
|
||||
echo "Registry secrets not configured, skipping push"
|
||||
exit 0
|
||||
fi
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin
|
||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:latest"
|
||||
docker push "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
||||
docker push "$REGISTRY_URL/life-towers:latest"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
# IMPORTANT: Before this workflow will function, configure the following
|
||||
# repository secrets in Forgejo (Settings → Secrets):
|
||||
# DEPLOY_HOST — hostname or IP of the target server
|
||||
# DEPLOY_USER — SSH user on the target server
|
||||
# DEPLOY_SSH_KEY — private SSH key (PEM or OpenSSH format)
|
||||
# DEPLOY_PATH — absolute path to the project directory on the server
|
||||
# (must contain a docker-compose.yml + a .env file
|
||||
# that sets LIFE_TOWERS_IMAGE to the registry tag,
|
||||
# e.g. LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest)
|
||||
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy via SSH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Pulls the new image referenced by $LIFE_TOWERS_IMAGE in the
|
||||
# server's .env, restarts the service, then verifies health.
|
||||
ssh -i ~/.ssh/deploy_key \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||
"set -euo pipefail
|
||||
cd '${{ secrets.DEPLOY_PATH }}'
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
# Wait for healthcheck (max ~60s)
|
||||
for i in \$(seq 1 30); do
|
||||
status=\$(docker compose ps --format json life-towers | python3 -c 'import sys,json;[print(json.loads(l).get(\"Health\",\"\")) for l in sys.stdin]' || true)
|
||||
if [ \"\$status\" = healthy ]; then echo deploy_healthy; exit 0; fi
|
||||
sleep 2
|
||||
done
|
||||
echo deploy_unhealthy >&2
|
||||
docker compose logs --tail 50 life-towers >&2
|
||||
exit 1"
|
||||
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
|
||||
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/`
|
||||
|
|
@ -44,6 +44,7 @@ USER appuser
|
|||
|
||||
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
||||
LIFE_TOWERS_STATIC_DIR=/app/static \
|
||||
LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/src \
|
||||
PATH=/app/.venv/bin:$PATH
|
||||
|
|
@ -53,4 +54,4 @@ 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=*"]
|
||||
CMD ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"]
|
||||
|
|
|
|||
14
README.md
|
|
@ -1,4 +1,6 @@
|
|||
# Life Towers
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.svg" alt="Life Towers" width="420">
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -21,8 +23,9 @@ Then visit http://localhost:8000.
|
|||
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
||||
docker compose pull && docker compose up -d
|
||||
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
|
@ -33,6 +36,7 @@ LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
|||
| `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. |
|
||||
|
||||
## Data persistence
|
||||
|
|
@ -89,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v
|
|||
|
||||
## 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.
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`.
|
||||
|
||||
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.
|
||||
The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-<commit>` on pushes to `master` or manual dispatch. It publishes to `vars.REGISTRY` (default `ghcr.io`) under the repository name, using `secrets.GITHUB_TOKEN` for registry auth.
|
||||
|
|
|
|||
|
|
@ -11,13 +11,6 @@ dependencies = [
|
|||
"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"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""APIRouter with all Life Towers endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from .auth import get_current_user
|
||||
from .db import db_connection
|
||||
from .limits import limiter
|
||||
from .logging import token_log_id
|
||||
from .models import (
|
||||
BlockOut,
|
||||
DataIn,
|
||||
|
|
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
|||
(now, token),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("user_registered", user_id=token, new=existing is None)
|
||||
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
|
||||
return RegisterResponse(user_id=token)
|
||||
|
||||
|
||||
|
|
@ -94,7 +93,7 @@ async def get_data(
|
|||
|
||||
block_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, tag, description, is_done, created_at
|
||||
SELECT id, tag, description, is_done, difficulty, created_at
|
||||
FROM blocks
|
||||
WHERE tower_id = ?
|
||||
ORDER BY position
|
||||
|
|
@ -108,6 +107,7 @@ async def get_data(
|
|||
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
|
||||
|
|
@ -213,8 +213,9 @@ async def put_data(
|
|||
"""
|
||||
INSERT INTO blocks
|
||||
(id, tower_id, user_id, position, tag,
|
||||
description, is_done, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
description, is_done, difficulty,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
block.id,
|
||||
|
|
@ -224,6 +225,7 @@ async def put_data(
|
|||
block.tag,
|
||||
block.description,
|
||||
1 if block.is_done else 0,
|
||||
block.difficulty,
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
|
|
@ -236,8 +238,14 @@ async def put_data(
|
|||
)
|
||||
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Submitted IDs conflict with existing data",
|
||||
) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
||||
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import db_connection
|
||||
from .models import _canonical_uuidv4
|
||||
|
||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||
# must not distinguish between missing / malformed / unknown tokens — that
|
||||
|
|
@ -21,25 +20,28 @@ def _unauthorized() -> HTTPException:
|
|||
)
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
def extract_bearer_token(request: Request) -> str | None:
|
||||
"""Return the raw Bearer token from the Authorization header, or None."""
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||
"authorization"
|
||||
)
|
||||
if not auth_header:
|
||||
raise _unauthorized()
|
||||
|
||||
return None
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise _unauthorized()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
token = parts[1]
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
token = extract_bearer_token(request)
|
||||
if token is None:
|
||||
raise _unauthorized()
|
||||
|
||||
try:
|
||||
u = uuid.UUID(token)
|
||||
if u.version != 4:
|
||||
raise ValueError("Not v4")
|
||||
except (ValueError, AttributeError):
|
||||
token = _canonical_uuidv4(token)
|
||||
except ValueError:
|
||||
raise _unauthorized()
|
||||
|
||||
with db_connection() as conn:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from fastapi import Request, Response
|
|||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from .auth import extract_bearer_token
|
||||
|
||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||
|
||||
_TOO_LARGE_BODY = json.dumps(
|
||||
|
|
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
|
|||
|
||||
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)
|
||||
return extract_bearer_token(request) or get_remote_address(request)
|
||||
|
||||
|
||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ from __future__ import annotations
|
|||
|
||||
import time
|
||||
import uuid as _uuid_mod
|
||||
from hashlib import sha256
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
|
||||
from .auth import extract_bearer_token
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structlog for JSON output."""
|
||||
|
|
@ -26,19 +29,19 @@ def configure_logging() -> None:
|
|||
)
|
||||
|
||||
|
||||
def token_log_id(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
||||
"""Log each request without writing bearer credentials to the log stream."""
|
||||
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]
|
||||
token = extract_bearer_token(request)
|
||||
user_id = token_log_id(token) if token else None
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
"""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, JSONResponse
|
||||
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
|
||||
|
|
@ -35,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
|
|||
413: "payload_too_large",
|
||||
422: "bad_request",
|
||||
429: "rate_limited",
|
||||
507: "quota_exceeded",
|
||||
500: "server_error",
|
||||
}
|
||||
|
||||
|
|
@ -92,10 +90,15 @@ def create_app() -> FastAPI:
|
|||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
fields = sorted(
|
||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
||||
field
|
||||
for field in {
|
||||
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
|
||||
for e in exc.errors()
|
||||
}
|
||||
if field
|
||||
)
|
||||
if fields:
|
||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||
detail_str = "Validation failed for: " + ", ".join(fields)
|
||||
else:
|
||||
detail_str = "Validation failed"
|
||||
return JSONResponse(
|
||||
|
|
@ -113,7 +116,7 @@ def create_app() -> FastAPI:
|
|||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||
detail = {"error": code, "detail": str(exc.detail)}
|
||||
|
||||
headers = getattr(exc, "headers", None) or {}
|
||||
headers = exc.headers or {}
|
||||
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||
|
||||
# Generic 500 handler
|
||||
|
|
@ -151,7 +154,44 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
|
||||
)
|
||||
|
||||
def _serve_file(file_path: Path) -> FileResponse:
|
||||
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"
|
||||
|
|
@ -160,7 +200,7 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
return resp
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str) -> Response:
|
||||
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/"):
|
||||
|
|
@ -174,12 +214,12 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if candidate.is_file():
|
||||
return _serve_file(candidate)
|
||||
return _serve_file(candidate, request)
|
||||
|
||||
# SPA fallback to index.html.
|
||||
index = static_dir / "index.html"
|
||||
if index.is_file():
|
||||
return _serve_file(index)
|
||||
return _serve_file(index, request)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
-- Life Towers v4 initial schema.
|
||||
-- SQLite with WAL mode and foreign keys enabled at connection time.
|
||||
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
|
||||
-- db._apply_pragmas(), so they are not (and need not be) set here.
|
||||
-- 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,
|
||||
|
|
@ -17,6 +15,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
|||
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,
|
||||
|
|
@ -47,6 +46,7 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (keep_tasks_open IN (0, 1));
|
||||
|
|
@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||
import uuid as _uuid_mod
|
||||
|
||||
|
||||
def _is_uuidv4(value: str) -> bool:
|
||||
def _canonical_uuidv4(value: str) -> str:
|
||||
try:
|
||||
u = _uuid_mod.UUID(value)
|
||||
return u.version == 4
|
||||
if u.version == 4:
|
||||
return str(u)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
pass
|
||||
raise ValueError("must be a UUIDv4")
|
||||
|
||||
|
||||
class HslColor(BaseModel):
|
||||
|
|
@ -27,14 +29,13 @@ class BlockIn(BaseModel):
|
|||
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
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class BlockOut(BaseModel):
|
||||
|
|
@ -42,6 +43,7 @@ class BlockOut(BaseModel):
|
|||
tag: str
|
||||
description: str
|
||||
is_done: bool
|
||||
difficulty: int
|
||||
created_at: int
|
||||
|
||||
|
||||
|
|
@ -54,9 +56,7 @@ class TowerIn(BaseModel):
|
|||
@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
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class TowerOut(BaseModel):
|
||||
|
|
@ -78,9 +78,7 @@ class PageIn(BaseModel):
|
|||
@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
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class PageOut(BaseModel):
|
||||
|
|
@ -137,9 +135,7 @@ class RegisterRequest(BaseModel):
|
|||
@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
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -51,15 +51,71 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
|
|||
db_module._DB_PATH = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/health")
|
||||
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 resp.json() == {"status": "ok"}
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -99,6 +155,21 @@ async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
|
|||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
upper_token = token.upper()
|
||||
register_resp = await client.post("/api/v1/register", json={"token": upper_token})
|
||||
assert register_resp.status_code == 200
|
||||
assert register_resp.json() == {"user_id": token}
|
||||
|
||||
data_resp = await client.get(
|
||||
"/api/v1/data",
|
||||
headers={"Authorization": f"Bearer {upper_token}"},
|
||||
)
|
||||
assert data_resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth / GET /data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -148,6 +219,7 @@ def _make_tree() -> dict:
|
|||
"tag": f"tag-{pi}-{ti}-{bi}",
|
||||
"description": f"desc-{pi}-{ti}-{bi}",
|
||||
"is_done": False,
|
||||
"difficulty": bi + 1,
|
||||
"created_at": 1700000000 + bi,
|
||||
}
|
||||
)
|
||||
|
|
@ -199,6 +271,42 @@ async def test_round_trip(client: AsyncClient) -> None:
|
|||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -218,6 +326,34 @@ async def test_put_duplicate_page_id(client: AsyncClient) -> None:
|
|||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||
assert resp.json() == {"error": "bad_request", "detail": "Validation failed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None:
|
||||
first_token = make_uuidv4()
|
||||
second_token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": first_token})
|
||||
await client.post("/api/v1/register", json={"token": second_token})
|
||||
|
||||
tree = _make_tree()
|
||||
first_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {first_token}"},
|
||||
)
|
||||
assert first_resp.status_code == 204
|
||||
|
||||
second_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {second_token}"},
|
||||
)
|
||||
assert second_resp.status_code == 409
|
||||
assert second_resp.json() == {
|
||||
"error": "conflict",
|
||||
"detail": "Submitted IDs conflict with existing data",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
11
backend/uv.lock
generated
|
|
@ -201,13 +201,6 @@ dependencies = [
|
|||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
|
|
@ -218,15 +211,11 @@ dev = [
|
|||
[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 = [
|
||||
|
|
|
|||
795
docs/DESIGN.md
|
|
@ -1,795 +0,0 @@
|
|||
# DESIGN.md — Life Towers Legacy Visual Spec (Angular 7 → 21 port)
|
||||
|
||||
Forensic reference for restoring pixel-perfect parity. Every snippet is quoted verbatim from `_legacy_reference/frontend/src/`. File paths are absolute.
|
||||
|
||||
---
|
||||
|
||||
## 1. Color tokens
|
||||
|
||||
`_legacy_reference/frontend/src/library/common-variables.scss:1-9`:
|
||||
|
||||
```scss
|
||||
$accent-color: #a2666f;
|
||||
$text-color: #5d576b;
|
||||
$light-color: #ffffff;
|
||||
|
||||
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%);
|
||||
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%);
|
||||
|
||||
$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);
|
||||
```
|
||||
|
||||
`index.html:14`: `<meta name="theme-color" content="#5d576b" />` — iOS/Android theme bar = `$text-color`.
|
||||
|
||||
- `7f` hex alpha in `$background-gradient` is ~50% opacity. Opaque variant is used on `<body>`; semi-transparent is the **modal backdrop**.
|
||||
- `$shadow` is a layered "soft-glow border" — first ring 1.5px tight (10% black), second 3px diffuse (5% black). Reuse exactly.
|
||||
- `$shadow-border` is a 0.75px hairline used in place of CSS `border:` everywhere.
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
|
||||
Fonts loaded in `index.html:8-11, 17-18`:
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300|Raleway&display=swap&subset=latin-ext" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
```
|
||||
|
||||
`common-variables.scss:11-12`:
|
||||
```scss
|
||||
$normal-font: 'Open Sans Condensed', sans-serif;
|
||||
$title-font: 'Raleway', serif;
|
||||
```
|
||||
|
||||
Only **Open Sans Condensed Light 300** and **Raleway 400** are actually used in the visual design.
|
||||
|
||||
`library/text.scss:3-58`:
|
||||
```scss
|
||||
:root {
|
||||
--larger-font-size: 22px;
|
||||
--large-font-size: 18px;
|
||||
--medium-font-size: 16px;
|
||||
--small-font-size: 11px;
|
||||
|
||||
@media (max-width: $mobile-width) { // 520px
|
||||
--larger-font-size: 20px;
|
||||
--large-font-size: 16px;
|
||||
--medium-font-size: 14px;
|
||||
--small-font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin title-text { font-family: $title-font; color: $text-color; font-size: var(--larger-font-size); user-select: none; }
|
||||
@mixin sub-title-text { font-family: $title-font; color: $text-color; font-size: var(--medium-font-size); user-select: none; }
|
||||
@mixin normal-text { font-family: $normal-font; color: $text-color; font-size: var(--larger-font-size); }
|
||||
@mixin medium-text { font-family: $normal-font; color: $text-color; font-size: var(--medium-font-size); }
|
||||
@mixin small-text { font-family: $normal-font; color: $text-color; font-size: var(--small-font-size); }
|
||||
|
||||
h1, h2, h3 { @include title-text(); }
|
||||
p { @include normal-text(); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Spacing tokens
|
||||
|
||||
`library/main.scss:8-22`:
|
||||
```scss
|
||||
:root {
|
||||
--border-radius: 5px;
|
||||
--large-padding: 30px;
|
||||
--medium-padding: 15px;
|
||||
--small-padding: 10px;
|
||||
|
||||
@media (max-width: $mobile-width) { // 520px
|
||||
--border-radius: 3px;
|
||||
--large-padding: 20px;
|
||||
--medium-padding: 15px;
|
||||
--small-padding: 7.5px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Body padding is `var(--large-padding)` — 30px desktop / 20px mobile around everything.
|
||||
|
||||
Breakpoints:
|
||||
```scss
|
||||
$mobile-width: 520px;
|
||||
$min-height: 400px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Animations
|
||||
|
||||
`library/animations.scss:1-22` (full file):
|
||||
```scss
|
||||
@import 'common-variables';
|
||||
|
||||
$long-animation-time: 200ms;
|
||||
$short-animation-time: 100ms;
|
||||
|
||||
@mixin gravitate {
|
||||
cursor: pointer;
|
||||
transition: box-shadow $long-animation-time, transform $long-animation-time;
|
||||
&:hover {
|
||||
box-shadow: $shadow;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin jump {
|
||||
cursor: pointer;
|
||||
transition: transform $long-animation-time;
|
||||
&:hover { transform: translateY(-2px); }
|
||||
}
|
||||
```
|
||||
|
||||
### 4a. The "falling animation" (THE critical interaction)
|
||||
|
||||
A block transitions from above the tower top (`translateY(500%)`) down into its slot. The `.block-container` is `transform: scaleY(-1)` (flipped). Visually each new block drops from the top of the tower and lands on top of the previous ones. The ease curve `cubic-bezier(0.5, 0, 1, 0)` is a steep accelerating ease-in (gravity).
|
||||
|
||||
`tower.component.scss:115-140`:
|
||||
```scss
|
||||
.block-container-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
.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);
|
||||
|
||||
* { 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Driver pattern (`tower.component.ts:67-98`):
|
||||
```ts
|
||||
const lastBlock = top(this.styledBlocks);
|
||||
if (lastBlock) {
|
||||
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
|
||||
setTimeout(() => {
|
||||
this.makeBlockDescend(lastBlock);
|
||||
this.changeDetection.markForCheck();
|
||||
}, 0);
|
||||
}
|
||||
...
|
||||
makeBlockDescend(block) { block.cssClass = 'descend'; block.style = { transform: 'translateY(0)', opacity: '1' }; }
|
||||
makeBlockAscend(block) { block.cssClass = 'ascend'; block.style = { transform: 'translateY(500%)', opacity: '0' }; }
|
||||
```
|
||||
|
||||
Sequence on add: place the new block at `translateY(500%)/opacity:0` synchronously, then on next tick apply `.descend` class + reset transform to `translateY(0)/opacity:1`. The 1.5s gravity cubic curve does the fall; opacity fades in over the first 500ms.
|
||||
|
||||
On ascend: same curve but the opacity delay is **1s** so the block stays visible for most of the upward flight, then fades just before leaving.
|
||||
|
||||
### 4b. Other timed transitions
|
||||
|
||||
- Modal backdrop opacity: `300ms`.
|
||||
- Tower hover-shadow + scale: `gravitate()` mixin = 200ms.
|
||||
- Tower drag-and-drop reflow: `transform 200ms cubic-bezier(0, 0, 0.2, 1)`.
|
||||
- cdkDrag animating: `transform 250ms cubic-bezier(0, 0, 0.2, 1)`.
|
||||
- Trash icon scale-in: `transform 200ms`.
|
||||
- Toggle thumb slide: `box-shadow/left/transform 200ms`.
|
||||
- Select-add dropdown: `transform 200ms translateY(-100%) → none`; background height 200ms.
|
||||
- Button underline (`forms.scss:78`): `width 300ms`.
|
||||
- Tasks accordion `.all-task`: `height 200ms`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mixins (verbatim, ready to port)
|
||||
|
||||
`library/utils.scss`:
|
||||
```scss
|
||||
@mixin inner-spacing($spacing, $horizontal: false) {
|
||||
& > *:not(:last-child) {
|
||||
@if $horizontal { margin-right: $spacing; }
|
||||
@else { margin-bottom: $spacing; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`library/spacing.scss` (despite filename, contains `square`):
|
||||
```scss
|
||||
@mixin square($size) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
```
|
||||
|
||||
`library/main.scss:24-43`:
|
||||
```scss
|
||||
@mixin card {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: $light-color;
|
||||
}
|
||||
|
||||
@mixin center-child {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@mixin exit {
|
||||
@include square(16px);
|
||||
background: url('/assets/x-sign.svg') no-repeat center center;
|
||||
background-size: 50% 50%;
|
||||
box-sizing: content-box;
|
||||
padding: 8px;
|
||||
@include jump();
|
||||
}
|
||||
```
|
||||
|
||||
`library/forms.scss:1-85` — global form styling:
|
||||
```scss
|
||||
@import 'text';
|
||||
@import 'animations';
|
||||
|
||||
textarea {
|
||||
@include normal-text();
|
||||
&:disabled { background-color: $light-color; }
|
||||
display: block; width: 100%; height: 150px;
|
||||
@media (max-width: $mobile-width) { height: 100px; }
|
||||
resize: none; box-sizing: border-box; border: none;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
@include sub-title-text();
|
||||
width: 100%; background: transparent; display: block; border: 0;
|
||||
&::placeholder { color: inherit; opacity: 0.6; }
|
||||
&:focus { box-shadow: 0 1px $text-color; }
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-appearance: none;
|
||||
margin: 8px auto 0 auto;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@include medium-text();
|
||||
font-size: var(--large-font-size);
|
||||
$height: 2px;
|
||||
cursor: pointer;
|
||||
border-bottom: solid $height #5d576b55;
|
||||
position: relative;
|
||||
&:disabled {
|
||||
color: #5d576b55;
|
||||
border-bottom: solid $height #5d576b33;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:not(:disabled):hover { &:after { width: 100%; } }
|
||||
&:after {
|
||||
content: '';
|
||||
width: 0; height: $height;
|
||||
position: absolute; left: 0;
|
||||
bottom: calc(-1 * #{$height});
|
||||
background-color: $text-color;
|
||||
transition: width 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
label { display: none; }
|
||||
```
|
||||
|
||||
Global root + scrollbar (`styles.scss` + `main.scss:45-68`):
|
||||
```scss
|
||||
* { margin: 0; padding: 0;
|
||||
&:active, &:focus { outline: 0; }
|
||||
&::selection { background: $text-color; color: $light-color; }
|
||||
&::placeholder { user-select: none; }
|
||||
}
|
||||
html { height: 100%; background-color: $text-color; }
|
||||
body { height: 100%; background: $background-gradient-opaque; text-align: center; padding: var(--large-padding); box-sizing: border-box; position: relative; }
|
||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
*::-webkit-scrollbar-track { box-shadow: $shadow-border; border-radius: var(--border-radius); }
|
||||
*::-webkit-scrollbar-thumb { background-color: $text-color; border-radius: var(--border-radius); cursor: pointer; }
|
||||
* { -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
||||
```
|
||||
|
||||
`$line-height: 2px;` is declared in `styles.scss:3` and reused by exit-pen underline, slider track, etc.
|
||||
|
||||
---
|
||||
|
||||
## 6. Layout rules per component
|
||||
|
||||
### 6a. Pages (top page selector) — `pages.component`
|
||||
|
||||
There is **no traditional tab strip**. The "page picker" is the `<app-select-add>` dropdown inside `.select-add-container` with `width: 250px; margin: auto`. It serves as both selector ("Add a new page…" placeholder) and editor (`editable=true` shows pen icon).
|
||||
|
||||
```scss
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.select-add-container { width: 250px; margin: 0 auto; }
|
||||
.page-container { flex: 1 0 auto; }
|
||||
button {
|
||||
transition: opacity $long-animation-time;
|
||||
&.transparent { opacity: 0; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Settings button at bottom fades to opacity 0 while a tower is dragging.
|
||||
|
||||
### 6b. Page (towers container) — `page.component`
|
||||
|
||||
Magic geometry:
|
||||
```scss
|
||||
.towers {
|
||||
display: flex; justify-content: center;
|
||||
width: 100%; margin: 0 auto;
|
||||
flex: 1 0 auto;
|
||||
transition: box-shadow $short-animation-time;
|
||||
max-width: 800px;
|
||||
|
||||
&.cdk-drop-list-dragging {
|
||||
*:not(.cdk-drag-placeholder) {
|
||||
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
div { @include center-child(); // add-tower wrapper
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
max-width: 200px;
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
@media (max-width: $mobile-width) { margin-right: var(--small-padding); }
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
|
||||
@for $i from 1 to 6 {
|
||||
& > *:first-child:nth-last-child(#{$i}),
|
||||
& > *:first-child:nth-last-child(#{$i}) ~ * {
|
||||
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
|
||||
@media (max-width: $mobile-width) {
|
||||
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Max 5 towers per page. Each tower gets an equal column up to 200px wide.
|
||||
|
||||
Trash icon:
|
||||
```scss
|
||||
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); }
|
||||
}
|
||||
```
|
||||
|
||||
### 6c. Tower — `tower.component`
|
||||
|
||||
Tower header: the `<input type="text">` for the tower name (font: `var(--small-font-size)`, centered, width 50% desktop). Color = tower's `baseColor` HSL.
|
||||
|
||||
Card body:
|
||||
```scss
|
||||
.container {
|
||||
display: flex; flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
width: 100%;
|
||||
|
||||
:before { // red flash overlay during trash-highlight
|
||||
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;
|
||||
}
|
||||
|
||||
img { // the plus-sign button inside the tower
|
||||
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; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hover shows `$shadow` above `$mobile-width`. `.trash-highlight` class shrinks to `scale(0.75)`, bumps `:before` to 0.5 opacity, hides the name input.
|
||||
|
||||
### 6d. Block — `block.component` ⭐ CRITICAL VISUAL DIVERGENCE
|
||||
|
||||
**A block is purely a colored square** — sized to 1/6th of the tower width. No tag label, no description, no done-state. The visual distinction is "it's IN THE TOWER" (done) vs "it's in the TASKS accordion" (pending).
|
||||
|
||||
```html
|
||||
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="handleClick()"></div>
|
||||
```
|
||||
|
||||
```scss
|
||||
:host {
|
||||
position: relative;
|
||||
width: calc(100% / 6);
|
||||
padding-bottom: calc(100% / 6); // forces aspect-ratio 1:1
|
||||
div {
|
||||
position: absolute;
|
||||
width: 100%; height: 100%;
|
||||
@include gravitate(); // hover shadow + scale 1.1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-block color (`model/tower.ts:52-54`):
|
||||
```ts
|
||||
getColorOfTag(tag: string): IColor {
|
||||
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// utils/color.ts
|
||||
export const lighten = (by: number, { h, s, l }: IColor): IColor => {
|
||||
let newL = l + by;
|
||||
if (newL > 100) newL = 100;
|
||||
else if (newL < 0) newL = 0;
|
||||
return { h, s, l: newL };
|
||||
};
|
||||
```
|
||||
|
||||
Deterministic hash of tag → `[0,1)`, centered at 0.5 → `[-0.5, +0.5)`, scaled ×50 → `[-25, +25)` lightness offset added to tower's HSL. **All blocks in a tower vary in lightness only**, around the tower's baseColor.
|
||||
|
||||
### 6e. Tasks (pending blocks accordion) — `tasks.component` ⭐ MISSING IN NEW APP
|
||||
|
||||
Tasks is **not** a sub-modal — it's an in-tower accordion listing **pending** (not-done) blocks. Sits ABOVE the falling-blocks area, inside each tower.
|
||||
|
||||
```scss
|
||||
:host {
|
||||
width: 100%; box-sizing: border-box;
|
||||
position: relative; z-index: 100000;
|
||||
|
||||
.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); }
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
transition: height $long-animation-time;
|
||||
overflow-y: hidden;
|
||||
|
||||
.task-container {
|
||||
display: flex; align-items: center;
|
||||
&:hover p { @media (min-width: $mobile-width) { color: inherit !important; } }
|
||||
div { // colored dot per task
|
||||
flex: 0 0 auto;
|
||||
margin: 0 calc(var(--small-padding) / 2) 0 0;
|
||||
@include square(var(--small-padding));
|
||||
@media (max-width: $mobile-width) { @include square(calc(var(--small-padding) / 2)); }
|
||||
}
|
||||
p {
|
||||
white-space: nowrap; text-overflow: ellipsis; overflow-x: hidden;
|
||||
text-align: left;
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
|
||||
}
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Header: `<strong>N</strong> task(s)`. Click expands `.all-task` from `height: 0` to `scrollHeight` in 200ms. Each row: colored dot (size `var(--small-padding)`) + description with ellipsis. Text color is the block's color; hover resets to inherit.
|
||||
|
||||
### 6f. Modal shell — `modal.component`
|
||||
|
||||
```scss
|
||||
section {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 10000;
|
||||
@include center-child();
|
||||
padding: var(--large-padding);
|
||||
box-sizing: border-box;
|
||||
background: $background-gradient; // semi-transparent warm gradient!
|
||||
transition: opacity 300ms;
|
||||
&:not(.active) { opacity: 0; pointer-events: none; }
|
||||
button { margin-top: var(--medium-padding); }
|
||||
}
|
||||
```
|
||||
|
||||
Modal backdrop = the warm cream→pink gradient at 50% alpha layered over the app. **Distinctive.** Open transition opacity 0→1 in 300ms.
|
||||
|
||||
### 6g. Sub-modals (settings / remove-page / remove-tower / blocks-edit)
|
||||
|
||||
All small modals share the same card shell:
|
||||
|
||||
```scss
|
||||
section / :host {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px; // settings = 400, remove-* = 500
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
@include exit(); // x-sign.svg, 16px box, 8px padding, jump hover
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Block-edit modal is a **horizontally scrolling carousel** of cards. Each card 66vw / max 400px. Two transparent spacer cards at start/end so the active card centers. `.mask` overlay fades opaque on inactive neighbours. Snap-to-center on scroll-end (150ms idle).
|
||||
|
||||
`get-started.component`: stub — skip in port.
|
||||
|
||||
### 6h. Toggle (custom switch)
|
||||
|
||||
A **dual-label switch**: left label + oval track + right label. Active-side label gets `font-weight: bold`. Hover nudges the thumb 2px toward the other side.
|
||||
|
||||
```scss
|
||||
:host {
|
||||
$size: 30px;
|
||||
@include center-child();
|
||||
@include inner-spacing(var(--medium-padding), $horizontal: true);
|
||||
|
||||
span {
|
||||
@include medium-text();
|
||||
max-width: 3 * $size;
|
||||
cursor: pointer;
|
||||
&.active { font-weight: bold; }
|
||||
&:first-of-type { text-align: right; }
|
||||
&:last-of-type { text-align: left; }
|
||||
}
|
||||
|
||||
label { display: block;
|
||||
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; }
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover:after { box-shadow: $shadow; transform: translateX(2px); }
|
||||
&.on:hover:after { transform: translateX(-2px); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6i. Select-add — `select-add.component`
|
||||
|
||||
A custom dropdown that doubles as inline creator (with `+ Add` button) and optional inline editor (pen icon, `editable=true`).
|
||||
|
||||
Top bar = white card showing selected text + arrow. Click → `.bottom` slides via `transform: translateY(-100%) → none` over 200ms. Other options listed as `<p>` rows. Bottom: text input + Add button + optional pen icon. Arrow rotates 180° when open.
|
||||
|
||||
```scss
|
||||
.background {
|
||||
position: absolute; top: 0;
|
||||
height: 100%; width: 100%;
|
||||
@include card();
|
||||
z-index: 3;
|
||||
transition: box-shadow $long-animation-time, height $long-animation-time;
|
||||
&.active { box-shadow: $shadow; }
|
||||
}
|
||||
&:hover { @media (min-width: $mobile-width) { .background { box-shadow: $shadow; } } }
|
||||
&.shadow-border { .background.active { box-shadow: $shadow-border; } }
|
||||
```
|
||||
|
||||
Flags: `alwaysDropShadow` pre-applies open shadow. `onlyShadowBorder` swaps soft shadow for hairline (used inside block-edit modal).
|
||||
|
||||
### 6j. Double-slider (date-range — NOT an HSL picker)
|
||||
|
||||
CRITICAL CLARIFICATION: legacy `double-slider` is a **two-thumb date-range slider** filtering blocks by `created` date — not an HSL color picker. The HSL color picker for tower base-color doesn't exist in the legacy reference (the tower color was likely set elsewhere or hardcoded). This is what makes the page "beautiful": as a thumb approaches a date label, the label slides upward and rotates -45°, like magnetic markers.
|
||||
|
||||
```scss
|
||||
$height: 70px;
|
||||
$width: 300px;
|
||||
$slider-size: 40px;
|
||||
|
||||
.container {
|
||||
width: $width;
|
||||
height: $height;
|
||||
position: relative;
|
||||
margin: $slider-size / 2 auto 0 auto;
|
||||
|
||||
label { display: none; }
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
position: absolute; left: 0;
|
||||
-webkit-appearance: none; outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: $slider-size; // 40px
|
||||
width: $slider-size;
|
||||
border-radius: 1000px;
|
||||
background-color: $light-color;
|
||||
transform-origin: center center;
|
||||
transform: translateY(-$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(-$slider-size / 2 + $line-height / 2) scale(1.1);
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: $line-height; // 2px
|
||||
background-color: $text-color;
|
||||
border-radius: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.value-container {
|
||||
@include small-text();
|
||||
display: flex; justify-content: space-evenly;
|
||||
span { display: block; margin-top: 10px; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two stacked `<input type="range">` on the same track. White 40px circular thumbs, 2px solid track. Hover scales thumb 1.1× and adds `$shadow`.
|
||||
|
||||
Labels: `getOffset(index)` for each of 6 evenly-spaced date labels, compute distance to nearer thumb (normalized `[0,1]`); within 0.2 "active zone" the label translates upward by `(1 - d/0.2) * 30px`, rotated -45°.
|
||||
|
||||
---
|
||||
|
||||
## 7. Drag-and-drop
|
||||
|
||||
Tower list is a `cdkDropList cdkDropListOrientation="horizontal"`. Each `<app-tower>` is `cdkDrag`. Cursor: `pointer` (not grab/grabbing).
|
||||
|
||||
- `.cdk-drag-animating` → tower `transition: transform 250ms cubic-bezier(0,0,0.2,1)`.
|
||||
- `.cdk-drag-placeholder` → `opacity: 0`.
|
||||
- `.cdk-drag-preview` → mobile fades `box-shadow` in via inline `@keyframes shadow` over 200ms.
|
||||
- `.cdk-drop-list-dragging *:not(.cdk-drag-placeholder)` → `transition: transform 200ms cubic-bezier(0,0,0.2,1)`.
|
||||
|
||||
Trash interaction (`page.component.ts:69-91`):
|
||||
- `pointerenter` on trash → `nearTrashcan=true`, append `' trash-highlight'` to `.cdk-drag-preview`'s className.
|
||||
- `pointerleave` → remove class.
|
||||
- `pointerup` on trash → open remove-tower confirm modal.
|
||||
|
||||
During drag:
|
||||
- `isDragging` true → trash icon `.active` springs in.
|
||||
- `isDragHappening` emitted up → Settings button fades to opacity 0.
|
||||
- Date-slider container fades to opacity 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Image assets
|
||||
|
||||
All SVGs in `_legacy_reference/frontend/src/assets/`:
|
||||
|
||||
| File | Where used | Size | Behaviour |
|
||||
|---|---|---|---|
|
||||
| `arrow.svg` | `select-add` top bar | `square(16px)` | rotates `-180deg` open, transition 200ms |
|
||||
| `pen.svg` | `select-add` edit button, blocks-modal edit | `square(16px)` in wrapper | `opacity: 0.25 → 0.5 (hover) → 1 (active)`; `:before` 2px underline expands 0→100% over 200ms |
|
||||
| `plus-sign.svg` | Tower internal add-block, end-of-row add-tower | `height: 48px` desktop, `32px` mobile | `opacity: 0.33 → 1` (hover) over 200ms |
|
||||
| `trash.svg` | Page absolute-positioned trash zone | `square(48px); padding: 16px` (80×80 hit box), `bottom: 8px; left: 50%` | `scale(0) → scale(1) (.active) → scale(1.1) (hover)`; `translateX(-50%)` preserved |
|
||||
| `x-sign.svg` | All modal exit buttons | 16px inner, 8px padding, `background-size: 50% 50%` | `@include jump()` hover lift |
|
||||
|
||||
---
|
||||
|
||||
## 9. Per-state styling
|
||||
|
||||
### Block
|
||||
- **Pending** (`!isDone`): appears **only in `<app-tasks>` accordion** as a colored-dot row.
|
||||
- **Done** (`isDone === true`): appears as a 1/6-tower-width colored square in the falling stack.
|
||||
- **Filtered out by date slider**: `.ascend` class, transitions out over 1.5s (opacity delayed 1s).
|
||||
- **Hover** (done block): `gravitate()` → `box-shadow: $shadow` + `transform: scale(1.1)` in 200ms.
|
||||
|
||||
### Tower
|
||||
- **Idle**: white card.
|
||||
- **Hover** (desktop): `box-shadow: $shadow` over 200ms.
|
||||
- **Dragging preview**: mobile fades shadow in over 200ms.
|
||||
- **Drag placeholder**: `opacity: 0`.
|
||||
- **Over trash (`.trash-highlight`)**: `scale(0.75)`, red overlay at 0.5 opacity, name input hidden.
|
||||
|
||||
### Page-tab (select-add for pages)
|
||||
No active/inactive — only "currently selected page" at top of dropdown.
|
||||
- **Closed**: white card, hairline shadow on hover.
|
||||
- **Open**: `$shadow` (or `$shadow-border` if `onlyShadowBorder`).
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Drop `library/*.scss` into `frontend/src/library/` verbatim. Update `styles.scss` to import them.
|
||||
2. Apply global body/html/scrollbar styles.
|
||||
3. Load Google Fonts (Open Sans Condensed 300 + Raleway). Drop the self-hosted-fonts I added if they're locked at incorrect weights — re-verify woff2 files cover the right weights and rewrite @font-face cleanly. Keep self-hosting if you want, just match the weights exactly.
|
||||
4. Set `<meta name="theme-color" content="#5d576b" />`.
|
||||
5. Build `select-add`, `toggle`, `double-slider` first.
|
||||
6. Build modal shell + sub-modals using the shared card recipe.
|
||||
7. Build tower → block → tasks.
|
||||
8. Build page with the `@for $i from 1 to 6` width calc and trash zone.
|
||||
9. Wire the "falling animation" exactly per §4a — the `setTimeout(..., 0)` two-step is essential.
|
||||
|
||||
---
|
||||
|
||||
## Critical model recap for implementers
|
||||
|
||||
- **Block has a `tag`** (string) and **does NOT have a description** in the legacy UI. The "description" field in the new app is not in the legacy data model — the legacy block is just `{ id, tag, isDone, created }` plus optionally derived data. Verify against the legacy `block.ts` and `IBlock` interface. The NEW backend's normalized schema has a `description` — keep it, but make it OPTIONAL and don't render it as the block's primary visual.
|
||||
- **Tower color picker**: NOT in the legacy reference. The new app's color-picker exists; align its visuals with the rest of the design (white card, $shadow, etc.) but don't pretend it matches a legacy that didn't exist.
|
||||
- **Date-range filter**: the double-slider in the legacy filtered blocks by their `created` date. Decide whether to port this (recommended — it's the "beautiful slider" the user remembers).
|
||||
127
docs/api-spec.md
|
|
@ -1,127 +0,0 @@
|
|||
# Life Towers API specification
|
||||
|
||||
This is the single source of truth for the v4 HTTP API between the Angular SPA and the FastAPI backend. Both clients and the server MUST conform to the shapes and rules defined here.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All IDs are UUIDv4 strings (lowercase, canonical hex with dashes).
|
||||
- All timestamps are Unix epoch seconds as integers.
|
||||
- All requests and responses are `application/json` unless noted.
|
||||
- Auth is `Authorization: Bearer <token>` where `<token>` is a UUIDv4 generated client-side at first launch.
|
||||
- Same-origin: the frontend is served by the same FastAPI process, so CORS is locked to the deployment origin (or fully disabled in same-origin mode).
|
||||
- All payloads are size-capped at **2 MiB**. Server returns `413 Payload Too Large` on overflow.
|
||||
- Because `PUT /api/v1/data` atomically replaces the user's entire tree, the request size **is** the user's total storage — there is no separate per-user quota.
|
||||
- Every response carries an `X-Request-Id` (UUIDv4) for log correlation.
|
||||
|
||||
## Authentication
|
||||
|
||||
A "user" is identified solely by a token (UUIDv4). There is no password, email, or recovery mechanism. The token is the credential — losing it means losing the data.
|
||||
|
||||
- The token is generated on the client at first launch (`crypto.randomUUID()`).
|
||||
- The client calls `POST /api/v1/register` to claim the token. Idempotent — if the token already exists the server returns `200 OK` with the existing record.
|
||||
- All authenticated endpoints require `Authorization: Bearer <token>`. Missing/malformed → `401`. Token not a valid UUIDv4 → `401`. Token not in DB → `401`. The `detail` string is identical across all 401 causes so the response cannot be used to enumerate tokens.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/v1/health`
|
||||
|
||||
Public liveness probe. Returns `200 {"status":"ok"}`. No auth.
|
||||
|
||||
### `POST /api/v1/register`
|
||||
|
||||
Body: `{"token": "<uuidv4>"}`. Creates the user if absent; updates `last_seen_at` if present. Returns `200 {"user_id": "<uuidv4>"}`.
|
||||
|
||||
Rate limit: **30 requests / hour / IP**.
|
||||
|
||||
### `GET /api/v1/data`
|
||||
|
||||
Returns the full hierarchy belonging to the authenticated user. Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "string",
|
||||
"hide_create_tower_button": false,
|
||||
"default_date_from": 1700000000, // or null
|
||||
"default_date_to": 1700090000, // or null
|
||||
"towers": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "string",
|
||||
"base_color": { "h": 0.5, "s": 0.8, "l": 0.6 },
|
||||
"blocks": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"tag": "string",
|
||||
"description": "string",
|
||||
"is_done": false,
|
||||
"created_at": 1700000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Empty user → `200 {"pages": []}`.
|
||||
|
||||
Rate limit: **60 / minute / token**.
|
||||
|
||||
### `PUT /api/v1/data`
|
||||
|
||||
Atomically replaces the entire user hierarchy with the request body. Request body has the same shape as the `GET /api/v1/data` response. Server enforces:
|
||||
|
||||
- Every `id` is a valid UUIDv4. The server does NOT enforce that IDs already exist — clients are free to mint new IDs. IDs MUST be unique within the request (no duplicates at any level).
|
||||
- All string fields are bounded:
|
||||
- `page.name`, `tower.name`, `block.tag`: ≤ 200 chars
|
||||
- `block.description`: ≤ 10 000 chars
|
||||
- Numeric bounds:
|
||||
- HSL components: `h ∈ [0,1]`, `s ∈ [0,1]`, `l ∈ [0,1]`
|
||||
- Page-level counts: ≤ 100 pages, ≤ 100 towers per page, ≤ 1000 blocks per tower
|
||||
- Total blocks across the user: ≤ 50 000
|
||||
- The whole replacement happens in a single SQLite transaction. Existing rows for the user are deleted and the new tree is inserted. The `users.last_seen_at` timestamp is updated.
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
Rate limit: **30 / minute / token**.
|
||||
|
||||
### Error responses
|
||||
|
||||
All error responses are JSON: `{"error": "code", "detail": "human-readable"}`. Codes the client must handle:
|
||||
|
||||
| HTTP | code | When |
|
||||
|------|-----------------------|---------------------------------------------------------|
|
||||
| 400 | `bad_request` | Malformed JSON, missing fields, validation failures |
|
||||
| 401 | `unauthorized` | Missing/invalid/unknown token |
|
||||
| 413 | `payload_too_large` | Request body > 2 MiB |
|
||||
| 429 | `rate_limited` | Rate limit exceeded. `Retry-After` header set |
|
||||
| 500 | `server_error` | Unexpected server failure. Body is generic, no stacktrace |
|
||||
|
||||
## SPA hosting
|
||||
|
||||
Any non-`/api/*` route is served from the static frontend build:
|
||||
|
||||
- `GET /` → `index.html`
|
||||
- `GET /assets/*`, `/favicon.ico`, `/manifest.webmanifest`, `/ngsw-worker.js`, hashed JS/CSS bundles → static file
|
||||
- `GET /<anything-else>` → `index.html` (SPA fallback for client-side routing)
|
||||
|
||||
Static files served with:
|
||||
- `Cache-Control: public, max-age=31536000, immutable` for hashed assets
|
||||
- `Cache-Control: no-cache` for `index.html`
|
||||
- gzip / brotli pre-compressed where available
|
||||
|
||||
## Removed since legacy
|
||||
|
||||
- `POST /` (replaced by `POST /api/v1/register`)
|
||||
- `POST /me` (the `track` endpoint — pure DOS vector, dropped entirely)
|
||||
- `GET /me/root`, `PUT /me/root` (folded into `GET/PUT /api/v1/data`)
|
||||
- `GET /me/<id>`, `POST /me/<id>` (per-object endpoints — replaced by tree-replace semantics)
|
||||
|
||||
## Future extensions (not in v1, but designed to allow)
|
||||
|
||||
- `GET /api/v1/data/stream` (Server-Sent Events) — push notifications of remote changes. Replaces polling for multi-device sync.
|
||||
- Signed share tokens for read-only sharing without giving away the full account token.
|
||||
|
|
@ -1,59 +1,11 @@
|
|||
# Frontend
|
||||
# Life Towers 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:
|
||||
Angular frontend for the Life Towers app. See the root `README.md` for the full
|
||||
stack setup.
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
npm start # dev server on :4200, with /api proxied to :8000
|
||||
npm test # Vitest unit tests
|
||||
npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000
|
||||
npm run build # production bundle
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"packageManager": "npm",
|
||||
"schematicCollections": [
|
||||
"angular-eslint"
|
||||
]
|
||||
],
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
|
|
@ -76,9 +76,6 @@
|
|||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
|
|
@ -91,4 +88,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
async function expectTaskListOpen(page: Page, description: string): Promise<void> {
|
||||
const tasks = page.locator('lt-tasks', { hasText: description }).first();
|
||||
const taskBody = tasks.locator('.all-task');
|
||||
const taskRow = tasks.locator('.task-container', { hasText: description }).first();
|
||||
|
||||
await expect(tasks.locator('.header')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
taskBody.evaluate((el) => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
return height / Math.max(1, el.scrollHeight);
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(0.9);
|
||||
await taskRow.locator('.tickbox').click({ trial: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: drives the legacy-styled UI end-to-end.
|
||||
|
|
@ -11,8 +28,11 @@ 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 });
|
||||
// Wait for init, then dismiss the welcome modal so the page controls are reachable.
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible();
|
||||
|
||||
// Create a page via the select-add dropdown.
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
|
|
@ -24,7 +44,7 @@ test.describe('Life Towers smoke test', () => {
|
|||
|
||||
// Create a tower.
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
||||
await page.locator('input[placeholder="New tower"]').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.
|
||||
|
|
@ -43,27 +63,25 @@ test.describe('Life Towers smoke test', () => {
|
|||
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
||||
'Modernise the towers app',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).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();
|
||||
await page.locator('lt-tasks .header').click();
|
||||
await page.locator('lt-tasks .task-description').click();
|
||||
|
||||
// Toggle done in the block-edit modal — the right label is "Done".
|
||||
// Toggle done in the block-edit modal.
|
||||
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 page.locator('lt-block-edit .card.active').getByLabel('Already done').check();
|
||||
await putLanded;
|
||||
await page.locator('lt-block-edit .card.active .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// Done block now appears as a colored square in the tower's falling stack.
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
|
|
@ -73,7 +91,93 @@ test.describe('Life Towers smoke test', () => {
|
|||
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 input').first()).toHaveValue('Side projects');
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('keep tasks open shows new pending tasks and survives immediate reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Work');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="New tower"]').fill('Today');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByText('Keep tasks open').click();
|
||||
await page.locator('lt-settings .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
const createCard = page.locator('lt-block-edit .create-card');
|
||||
await expect(createCard.getByLabel('Already done')).not.toBeChecked();
|
||||
await createCard.locator('lt-select-add .top').click();
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('ops');
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
await createCard
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Review deploy notes');
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await expectTaskListOpen(page, 'Review deploy notes');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Work', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expectTaskListOpen(page, 'Review deploy notes');
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await expect(page.locator('lt-block-edit .create-card').getByLabel('Already done')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('keep tasks open expands existing pending tasks after reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Ops');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="New tower"]').fill('Queue');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
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('triage');
|
||||
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('Clean up alerts');
|
||||
await page.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByText('Keep tasks open').click();
|
||||
await page.locator('lt-settings .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Ops', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expectTaskListOpen(page, 'Clean up alerts');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { test } from '@playwright/test';
|
||||
|
||||
test.skip(
|
||||
process.env['CAPTURE_VISUALS'] !== '1',
|
||||
'Set CAPTURE_VISUALS=1 to run the visual screenshot capture suite.',
|
||||
);
|
||||
|
||||
/**
|
||||
* Visual capture: drives the UI into key states and writes screenshots
|
||||
* for human review of the legacy-styled design.
|
||||
|
|
@ -7,8 +12,15 @@ import { test } from '@playwright/test';
|
|||
test.describe('Life Towers visuals', () => {
|
||||
test('capture key UI states', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('text=Add a new page to get started!', { timeout: 15000 });
|
||||
await page.screenshot({ path: 'visuals/01-empty-state.png', fullPage: true });
|
||||
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();
|
||||
|
|
@ -26,7 +38,7 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Reading');
|
||||
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' });
|
||||
|
|
@ -46,20 +58,26 @@ test.describe('Life Towers visuals', () => {
|
|||
await createCard
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Finish The Brothers Karamazov');
|
||||
// Toggle to "Task hasn't been finished yet" so this becomes a pending task.
|
||||
await createCard
|
||||
.locator('lt-toggle span')
|
||||
.filter({ hasText: "This task hasn't been finished yet" })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
// Uncheck "Already done" so this becomes a pending task.
|
||||
await createCard.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).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.locator('lt-tasks .header').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();
|
||||
|
|
@ -70,7 +88,7 @@ test.describe('Life Towers visuals', () => {
|
|||
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.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +118,7 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
||||
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);
|
||||
|
|
@ -157,4 +175,39 @@ test.describe('Life Towers visuals', () => {
|
|||
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 .header').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('lt-tasks .task-description').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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
26
frontend/package-lock.json
generated
|
|
@ -14,8 +14,8 @@
|
|||
"@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",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
|
@ -718,24 +718,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz",
|
||||
"integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.2.14",
|
||||
"@angular/core": "21.2.14",
|
||||
"@angular/platform-browser": "21.2.14",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/service-worker": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
||||
|
|
@ -3519,6 +3501,12 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@plausible-analytics/tracker": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
|
||||
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@
|
|||
"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"
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 15 KiB 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 |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB 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 |
BIN
frontend/public/og-image.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
|
@ -4,7 +4,6 @@ import {
|
|||
isDevMode,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
|
|
@ -12,7 +11,6 @@ export const appConfig: ApplicationConfig = {
|
|||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideHttpClient(withFetch()),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * 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 * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
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({
|
||||
|
|
@ -11,8 +12,10 @@ import { PagesComponent } from './components/pages/pages.component';
|
|||
})
|
||||
export class App implements OnInit {
|
||||
private readonly store = inject(StoreService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.init();
|
||||
this.analytics.init();
|
||||
void this.store.init();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color';
|
|||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div [style.background-color]="color()" (click)="clicked.emit()"></div>
|
||||
<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';
|
||||
|
|
@ -27,7 +36,16 @@ import { getColorOfTag } from '../../utils/color';
|
|||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include gravitate();
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&:hover,
|
||||
&.hovered {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
@ -35,6 +53,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
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>();
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
|
|
@ -15,7 +13,6 @@ import {
|
|||
} from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
export interface BlockEditSave {
|
||||
|
|
@ -24,25 +21,41 @@ export interface BlockEditSave {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
interface EditedValue {
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
function clampDifficulty(value: number): number {
|
||||
return Math.max(1, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function createDoneValue(defaultDone: boolean, currentDone: boolean, edited: boolean): boolean {
|
||||
return edited ? currentDone : defaultDone;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-block-edit',
|
||||
standalone: true,
|
||||
imports: [SelectAddComponent, ToggleComponent],
|
||||
imports: [SelectAddComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (viewTitle()) {
|
||||
<h2 class="view-title">{{ viewTitle() }}</h2>
|
||||
}
|
||||
|
||||
<section
|
||||
#container
|
||||
class="carousel"
|
||||
(scroll)="onScroll()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="card placeholder"></div>
|
||||
|
||||
|
|
@ -51,17 +64,27 @@ interface EditedValue {
|
|||
class="card"
|
||||
[class.active]="activeIdx() === i + 1"
|
||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
||||
(click)="onCardClick(i + 1)"
|
||||
(keydown.enter)="onCardClick(i + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||
></div>
|
||||
<h1>{{ formatDate(b.created_at) }}</h1>
|
||||
<h1>{{ formatDate(b.created_at, true) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
|
|
@ -70,7 +93,7 @@ interface EditedValue {
|
|||
[selected]="editedFor(b.id).tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
placeholder="Tag this item…"
|
||||
[placeholder]="tagPlaceholder('Tag this item…')"
|
||||
(select)="updateTag(b.id, $event)"
|
||||
(add)="updateTag(b.id, $event)"
|
||||
/>
|
||||
|
|
@ -78,18 +101,40 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="editedFor(b.id).description"
|
||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||
(blur)="flushExisting(b.id)"
|
||||
></textarea>
|
||||
|
||||
<div>
|
||||
<lt-toggle
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="editedFor(b.id).is_done"
|
||||
(checkedChange)="updateDone(b.id, $event)"
|
||||
offLabel="This task hasn't been finished yet"
|
||||
onLabel="Goal already accomplished"
|
||||
(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"
|
||||
[disabled]="editedFor(b.id).difficulty >= 100"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
|
|
@ -102,12 +147,22 @@ interface EditedValue {
|
|||
class="card create-card"
|
||||
[class.active]="activeIdx() === blocks().length + 1"
|
||||
[class.near-active]="activeIdx() === blocks().length"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Focus create card"
|
||||
(click)="onCardClick(blocks().length + 1)"
|
||||
(keydown.enter)="onCardClick(blocks().length + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfNewTag()"
|
||||
|
|
@ -115,13 +170,13 @@ interface EditedValue {
|
|||
<h1>Create now</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<div class="select-add-container" [class.required-empty]="!newValue().tag">
|
||||
<lt-select-add
|
||||
[items]="tags()"
|
||||
[selected]="newValue().tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
placeholder="Set a category…"
|
||||
[placeholder]="tagPlaceholder('Set a category…')"
|
||||
(select)="updateNewTag($event)"
|
||||
(add)="updateNewTag($event)"
|
||||
/>
|
||||
|
|
@ -129,17 +184,39 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="newValue().description"
|
||||
(input)="updateNewDescription($any($event.target).value)"
|
||||
></textarea>
|
||||
|
||||
<div>
|
||||
<lt-toggle
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="newValue().is_done"
|
||||
(checkedChange)="updateNewDone($event)"
|
||||
offLabel="This task hasn't been finished yet"
|
||||
onLabel="Goal already accomplished"
|
||||
(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"
|
||||
[disabled]="newValue().difficulty >= 100"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
|
|
@ -166,17 +243,51 @@ interface EditedValue {
|
|||
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;
|
||||
|
|
@ -192,8 +303,12 @@ interface EditedValue {
|
|||
flex: 0 0 auto;
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
scroll-snap-align: center;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
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;
|
||||
|
|
@ -246,6 +361,11 @@ interface EditedValue {
|
|||
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;
|
||||
}
|
||||
|
|
@ -253,10 +373,16 @@ interface EditedValue {
|
|||
.header {
|
||||
@include center-child();
|
||||
position: relative;
|
||||
gap: var(--small-padding);
|
||||
|
||||
h1 {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@include exit();
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +402,132 @@ interface EditedValue {
|
|||
}
|
||||
}
|
||||
|
||||
.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) {
|
||||
|
|
@ -291,10 +543,29 @@ interface EditedValue {
|
|||
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[]>([]);
|
||||
|
|
@ -317,7 +588,9 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: '',
|
||||
description: '',
|
||||
is_done: true,
|
||||
difficulty: 1,
|
||||
});
|
||||
private newDoneEdited = false;
|
||||
|
||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||
readonly activeIdx = signal(1);
|
||||
|
|
@ -334,6 +607,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: b.tag,
|
||||
description: b.description,
|
||||
is_done: b.is_done,
|
||||
difficulty: b.difficulty ?? 1,
|
||||
});
|
||||
}
|
||||
untracked(() => this.editedValues.set(m));
|
||||
|
|
@ -344,13 +618,24 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
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() });
|
||||
if (!cur.tag) {
|
||||
this.newValue.set({
|
||||
...cur,
|
||||
tag: t.length > 0 ? t[0] : '',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const isDone = this.defaultDone();
|
||||
untracked(() => {
|
||||
this.newValue.update((v) => ({
|
||||
...v,
|
||||
is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited),
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
|
@ -373,26 +658,44 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
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';
|
||||
private colorOfTag(tag: string): string {
|
||||
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
|
||||
}
|
||||
|
||||
colorOfNewTag = computed(() => {
|
||||
const t = this.newValue().tag;
|
||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
});
|
||||
colorOfTagForBlock(id: string): string {
|
||||
return this.colorOfTag(this.editedFor(id).tag);
|
||||
}
|
||||
|
||||
formatDate(ts: number): string {
|
||||
tagPlaceholder(fallback: string): string {
|
||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||
}
|
||||
|
||||
colorOfNewTag(): string {
|
||||
return this.colorOfTag(this.newValue().tag);
|
||||
}
|
||||
|
||||
formatDate(ts: number, compact = false): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +716,12 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = clampDifficulty(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);
|
||||
|
|
@ -426,7 +735,13 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
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 });
|
||||
this.save.emit({
|
||||
id,
|
||||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(id: string): void {
|
||||
|
|
@ -444,9 +759,14 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
updateNewDone(is_done: boolean): void {
|
||||
this.newDoneEdited = true;
|
||||
this.newValue.update((v) => ({ ...v, is_done }));
|
||||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
const v = this.newValue();
|
||||
if (!v.tag) return;
|
||||
|
|
@ -455,6 +775,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
this.close.emit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createDoneValue } from './block-edit.component';
|
||||
|
||||
describe('createDoneValue', () => {
|
||||
it('uses the create-card default before the user edits the checkbox', () => {
|
||||
expect(createDoneValue(false, true, false)).toBe(false);
|
||||
expect(createDoneValue(true, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the user-edited checkbox value', () => {
|
||||
expect(createDoneValue(false, true, true)).toBe(true);
|
||||
expect(createDoneValue(true, false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
output,
|
||||
input,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
|
|
@ -22,8 +23,20 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
class="modal"
|
||||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
|
||||
<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>
|
||||
|
|
@ -31,19 +44,35 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
section.modal {
|
||||
/* 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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
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: $background-gradient;
|
||||
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;
|
||||
|
|
@ -57,6 +86,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
`,
|
||||
})
|
||||
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
|
||||
|
|
@ -65,7 +96,6 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
private escListener!: (e: KeyboardEvent) => void;
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
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: 300px; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,6 @@ export interface UpdatePagePayload {
|
|||
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,
|
||||
|
|
@ -26,7 +23,7 @@ const UUIDV4_RE =
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close">✕</button>
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
<h2>Settings</h2>
|
||||
|
||||
@if (page()) {
|
||||
|
|
@ -36,26 +33,30 @@ const UUIDV4_RE =
|
|||
<input
|
||||
type="text"
|
||||
[value]="pageName()"
|
||||
(blur)="onRenamePage($any($event.target).value)"
|
||||
(blur)="onRenamePage($any($event.target))"
|
||||
placeholder="Page name…"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
aria-label="Page name"
|
||||
/>
|
||||
|
||||
<lt-toggle
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="onHideCreateTowerButtonChange($event)"
|
||||
offLabel="Show add-tower button"
|
||||
onLabel="Hide add-tower button"
|
||||
/>
|
||||
<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
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="onKeepTasksOpenChange($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
<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
|
||||
|
|
@ -68,7 +69,7 @@ const UUIDV4_RE =
|
|||
<section class="account-section">
|
||||
<h3>Account</h3>
|
||||
|
||||
<p class="hint">Your token (keep it secret — it IS your account)</p>
|
||||
<p class="hint">Copy this token to another device to permanently sync your progress</p>
|
||||
<div class="token-row">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -111,7 +112,11 @@ const UUIDV4_RE =
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 480px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -123,17 +128,19 @@ const UUIDV4_RE =
|
|||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
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(--large-font-size);
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
section {
|
||||
|
|
@ -151,8 +158,37 @@ const UUIDV4_RE =
|
|||
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(--small-font-size);
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
color: rgba($text-color, 0.7);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
|
@ -171,6 +207,13 @@ const UUIDV4_RE =
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,10 +252,15 @@ export class SettingsComponent {
|
|||
readonly hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
private static readonly 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;
|
||||
|
||||
// Token-switch state
|
||||
readonly tokenInput = signal('');
|
||||
readonly tokenInputTouched = signal(false);
|
||||
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
|
||||
readonly isValidToken = computed(() =>
|
||||
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
|
@ -225,10 +273,14 @@ export class SettingsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onRenamePage(value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onRenamePage(input: HTMLInputElement): void {
|
||||
const trimmed = input.value.trim();
|
||||
if (!trimmed) {
|
||||
input.value = this.pageName();
|
||||
return;
|
||||
}
|
||||
this.pageName.set(trimmed);
|
||||
input.value = trimmed;
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
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';
|
||||
|
|
@ -21,10 +23,7 @@ export interface TowerSettingsResult {
|
|||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
|
||||
</div>
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
|
|
@ -32,19 +31,22 @@ export interface TowerSettingsResult {
|
|||
name="towerName"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Tower name…"
|
||||
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
class="title-input"
|
||||
/>
|
||||
|
||||
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ tower() ? 'Save' : 'Create tower' }}
|
||||
</button>
|
||||
<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>
|
||||
`,
|
||||
|
|
@ -55,30 +57,45 @@ export interface TowerSettingsResult {
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@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;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
display: block;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
@ -90,6 +107,7 @@ export class TowerSettingsComponent implements OnInit {
|
|||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
|
|
@ -102,17 +120,38 @@ export class TowerSettingsComponent implements OnInit {
|
|||
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).
|
||||
this.tryEmitSave();
|
||||
}
|
||||
|
||||
private autoSave(): void {
|
||||
this.tryEmitSave();
|
||||
}
|
||||
|
||||
private tryEmitSave(): void {
|
||||
if (this.form.invalid) return;
|
||||
const v = this.form.value;
|
||||
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
|
||||
this.emitSave();
|
||||
}
|
||||
|
||||
private emitSave(): void {
|
||||
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
@for (tower of page().towers; track tower.id) {
|
||||
<lt-tower
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="modalOpen()"
|
||||
[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)"
|
||||
|
|
@ -21,7 +22,16 @@
|
|||
}
|
||||
@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)" />
|
||||
<img
|
||||
class="add-tower"
|
||||
src="assets/plus-sign.svg"
|
||||
alt="Add tower"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="showAddTower.set(true)"
|
||||
(keydown.enter)="showAddTower.set(true)"
|
||||
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -48,7 +58,7 @@
|
|||
|
||||
@if (showAddTower()) {
|
||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +66,7 @@
|
|||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative; // anchor for absolute-positioned .trash
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
|
@ -18,14 +19,17 @@
|
|||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
transition: box-shadow $short-animation-time;
|
||||
|
||||
max-width: 800px;
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&.cdk-drop-list-dragging {
|
||||
*:not(.cdk-drag-placeholder) {
|
||||
|
|
@ -52,28 +56,45 @@
|
|||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-right: var(--small-padding);
|
||||
}
|
||||
}
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
@for $i from 1 to 12 {
|
||||
& > *:first-child:nth-last-child(#{$i}),
|
||||
& > *:first-child:nth-last-child(#{$i}) ~ * {
|
||||
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
|
||||
// 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)
|
||||
);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +110,11 @@
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -101,7 +126,7 @@
|
|||
@include center-child();
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
|
@ -115,11 +140,21 @@
|
|||
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%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
HostListener,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
|
|
@ -15,7 +19,6 @@ 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';
|
||||
|
||||
|
|
@ -38,6 +41,7 @@ interface BlockPatch {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
|
|
@ -60,12 +64,14 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
|
|||
})
|
||||
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);
|
||||
|
|
@ -112,10 +118,30 @@ export class PageComponent {
|
|||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.showSlider()) {
|
||||
untracked(() => this.dateRange.set(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 {
|
||||
|
|
@ -131,7 +157,7 @@ export class PageComponent {
|
|||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
this.confirmDeleteTowerId.set(towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
|
@ -143,6 +169,7 @@ export class PageComponent {
|
|||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
result.difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -187,13 +214,16 @@ export class PageComponent {
|
|||
|
||||
onTrashEnter(): void {
|
||||
this.nearTrashcan = true;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.add('trash-highlight');
|
||||
this.dragPreview()?.classList.add('trash-highlight');
|
||||
}
|
||||
|
||||
onTrashLeave(): void {
|
||||
this.nearTrashcan = false;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.remove('trash-highlight');
|
||||
this.dragPreview()?.classList.remove('trash-highlight');
|
||||
}
|
||||
|
||||
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
|
||||
private dragPreview(): Element | null {
|
||||
return document.querySelector('.cdk-drag-preview');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
<div class="page-container">
|
||||
@if (selectedPage(); as page) {
|
||||
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
|
||||
<lt-page
|
||||
[page]="page"
|
||||
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||
(dragHappening)="dragHappening.set($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>Add a new page to get started!</p>
|
||||
}
|
||||
|
|
@ -27,14 +31,37 @@
|
|||
[page]="selectedPage()"
|
||||
(close)="showSettings.set(false)"
|
||||
(updatePage)="onUpdatePage($event)"
|
||||
(deletePage)="onRemovePage()"
|
||||
(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 (close)="showWelcome.set(false)">
|
||||
<lt-modal
|
||||
labelledBy="welcome-title"
|
||||
describedBy="welcome-description"
|
||||
(close)="showWelcome.set(false)"
|
||||
>
|
||||
<lt-welcome
|
||||
(close)="showWelcome.set(false)"
|
||||
(startFresh)="showWelcome.set(false)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -11,14 +12,24 @@
|
|||
|
||||
.select-add-container {
|
||||
width: 250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 80vw;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 0 auto;
|
||||
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 {
|
||||
|
|
@ -27,5 +38,65 @@
|
|||
&.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();
|
||||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
signal,
|
||||
computed,
|
||||
effect,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { PageComponent } from '../page/page.component';
|
||||
|
|
@ -22,7 +23,7 @@ import { Page } from '../../models';
|
|||
templateUrl: './pages.component.html',
|
||||
styleUrl: './pages.component.scss',
|
||||
})
|
||||
export class PagesComponent {
|
||||
export class PagesComponent implements OnDestroy {
|
||||
protected readonly store = inject(StoreService);
|
||||
|
||||
/** ID of currently selected page within store.pages(). */
|
||||
|
|
@ -31,22 +32,43 @@ export class PagesComponent {
|
|||
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) {
|
||||
const pages = this.store.pages();
|
||||
if (!this.store.loading() && pages.length === 0) {
|
||||
this.showWelcome.set(true);
|
||||
} else if (this.store.pages().length > 0) {
|
||||
} else if (pages.length > 0) {
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onLoadExample(): void {
|
||||
this.store.loadExample();
|
||||
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>(() => {
|
||||
|
|
@ -61,12 +83,17 @@ export class PagesComponent {
|
|||
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 pages = this.store.pages();
|
||||
const page = this.selectedPage();
|
||||
if (!page) return -1;
|
||||
return this.store.pages().findIndex((p) => p.id === page.id);
|
||||
return pages.findIndex((p) => p.id === page.id);
|
||||
});
|
||||
|
||||
onSelectPage(index: number): void {
|
||||
|
|
@ -94,12 +121,23 @@ export class PagesComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onRemovePage(): void {
|
||||
onRequestRemovePage(): void {
|
||||
const page = this.selectedPage();
|
||||
if (!page) return;
|
||||
this.store.deletePage(page.id);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ const FIXED_L = 0.55;
|
|||
display: block;
|
||||
padding: var(--medium-padding);
|
||||
@include card();
|
||||
box-shadow: $shadow-border;
|
||||
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 {
|
||||
|
|
@ -76,35 +79,40 @@ const FIXED_L = 0.55;
|
|||
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: $shadow-border;
|
||||
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: $shadow;
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow;
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.15);
|
||||
outline: 2px solid $light-color;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
padding: 8px 0;
|
||||
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
height: 16px;
|
||||
border-radius: 1000px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
|
|
@ -119,11 +127,15 @@ const FIXED_L = 0.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: 24px;
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, #{$light-color});
|
||||
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
|
||||
|
|
@ -135,8 +147,8 @@ const FIXED_L = 0.55;
|
|||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, white);
|
||||
border: 2px solid white;
|
||||
|
|
@ -153,7 +165,7 @@ const FIXED_L = 0.55;
|
|||
.preview {
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: $shadow-border;
|
||||
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
|
@ -173,9 +185,8 @@ export class ColorPickerComponent {
|
|||
return `hsl(${h}, 70%, 55%)`;
|
||||
}
|
||||
|
||||
toCss(c: HslColor): string {
|
||||
return toCss(c);
|
||||
}
|
||||
/** Re-exported so the template can call the utility directly. */
|
||||
readonly toCss = toCss;
|
||||
|
||||
pickHue(h: number): void {
|
||||
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface DoubleSliderRange<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.
|
||||
* approaches them (rotated -30°), per the legacy.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lt-double-slider',
|
||||
|
|
@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-1"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="oneValue()"
|
||||
(input)="oneValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-2"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="otherValue()"
|
||||
(input)="otherValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -65,6 +65,11 @@ export interface DoubleSliderRange<T> {
|
|||
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'] {
|
||||
|
|
@ -145,6 +150,12 @@ export interface DoubleSliderRange<T> {
|
|||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
@ -157,10 +168,9 @@ export class DoubleSliderComponent {
|
|||
|
||||
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
||||
|
||||
readonly MAX = 100;
|
||||
|
||||
readonly oneValue = signal(0);
|
||||
readonly otherValue = signal(this.MAX - 1);
|
||||
readonly otherValue = signal(0);
|
||||
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
|
||||
|
||||
private prevValuesLength = 0;
|
||||
|
||||
|
|
@ -187,42 +197,44 @@ export class DoubleSliderComponent {
|
|||
const hi = Math.max(a, b);
|
||||
untracked(() => {
|
||||
this.rangeChange.emit({
|
||||
from: vs[this.indexFromValue(lo)],
|
||||
to: vs[this.indexFromValue(hi)],
|
||||
from: vs[this.clampIndex(lo)],
|
||||
to: vs[this.clampIndex(hi)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Snap the higher thumb to MAX - 1 when a new entry is appended.
|
||||
// Snap the higher thumb to the newest value when a new entry is appended.
|
||||
effect(() => {
|
||||
const len = this.values().length;
|
||||
untracked(() => {
|
||||
const max = Math.max(0, len - 1);
|
||||
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);
|
||||
if (a > b) this.oneValue.set(max);
|
||||
else this.otherValue.set(max);
|
||||
} else {
|
||||
if (this.oneValue() > max) this.oneValue.set(max);
|
||||
if (this.otherValue() > max) this.otherValue.set(max);
|
||||
}
|
||||
this.prevValuesLength = len;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private indexFromValue(value: number): number {
|
||||
return Math.min(
|
||||
this.values().length - 1,
|
||||
Math.floor((value / this.MAX) * this.values().length),
|
||||
);
|
||||
private clampIndex(value: number): number {
|
||||
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetic label position: returns a CSS `transform` that lifts the label
|
||||
* upward and rotates -45° as a thumb approaches.
|
||||
* upward and rotates -30° 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 labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||
const max = Math.max(1, this.maxIndex());
|
||||
const a = this.oneValue() / max - 0.1;
|
||||
const b = this.otherValue() / 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%)';
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
|
|
@ -19,7 +20,15 @@ import {
|
|||
[class.always-shadow]="alwaysDropShadow()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div class="top" (click)="open.update(v => !v)">
|
||||
<div
|
||||
class="top"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-expanded]="open()"
|
||||
(click)="toggleOpen($event)"
|
||||
(keydown.enter)="toggleOpen($event)"
|
||||
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
|
||||
>
|
||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||
</div>
|
||||
|
|
@ -30,10 +39,13 @@ import {
|
|||
<input
|
||||
type="text"
|
||||
[value]="item"
|
||||
maxlength="200"
|
||||
(blur)="onRename(item, $any($event.target).value)"
|
||||
/>
|
||||
} @else {
|
||||
<p (click)="onSelectItem(item)">{{ item }}</p>
|
||||
<button class="option" type="button" (click)="onSelectItem(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="add-row">
|
||||
|
|
@ -41,11 +53,23 @@ import {
|
|||
type="text"
|
||||
#addInput
|
||||
placeholder="Add a value…"
|
||||
maxlength="200"
|
||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
||||
/>
|
||||
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button>
|
||||
<button
|
||||
class="add-button"
|
||||
type="button"
|
||||
(click)="onAdd(addInput.value); addInput.value = ''"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@if (editable()) {
|
||||
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
|
||||
<button
|
||||
class="pen"
|
||||
type="button"
|
||||
[class.active]="editing()"
|
||||
(click)="editing.update(v => !v)"
|
||||
>
|
||||
<img src="assets/pen.svg" alt="Edit" />
|
||||
</button>
|
||||
}
|
||||
|
|
@ -58,6 +82,7 @@ import {
|
|||
@import '../../../../library/main';
|
||||
|
||||
$inner-padding: var(--medium-padding);
|
||||
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
|
@ -80,13 +105,22 @@ import {
|
|||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
min-height: 46px;
|
||||
box-sizing: border-box;
|
||||
gap: var(--small-padding);
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
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;
|
||||
|
||||
|
|
@ -98,81 +132,129 @@ import {
|
|||
|
||||
.bottom-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
overflow-y: hidden;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
pointer-events: all;
|
||||
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: 0;
|
||||
@include inner-spacing($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(-100%);
|
||||
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: $shadow;
|
||||
// Show shadow on left/right/bottom only; clip the top edge so the
|
||||
// shadow doesn't bleed over the seam where .bottom meets .top.
|
||||
clip-path: inset(0 -6px -6px -6px);
|
||||
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;
|
||||
}
|
||||
|
||||
p {
|
||||
.option {
|
||||
@include sub-title-text();
|
||||
display: inline-block;
|
||||
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 {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) { height: 24px; }
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: static;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.add-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&.pen {
|
||||
opacity: 0.25;
|
||||
|
|
@ -184,6 +266,10 @@ import {
|
|||
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);
|
||||
}
|
||||
|
|
@ -224,16 +310,18 @@ import {
|
|||
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 {
|
||||
box-shadow: $shadow;
|
||||
// Show shadow on top/left/right only; clip the bottom edge so the
|
||||
// shadow doesn't bleed over the seam where .top meets .bottom.
|
||||
clip-path: inset(-6px -6px 0 -6px);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,25 +339,31 @@ import {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 { box-shadow: $shadow; }
|
||||
.background:not(.active) { box-shadow: $shadow; }
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border {
|
||||
.background.active {
|
||||
box-shadow: $shadow-border;
|
||||
clip-path: inset(-6px -6px 0 -6px);
|
||||
clip-path: inset(-10px -10px 0 -10px);
|
||||
}
|
||||
.bottom.open {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border:hover {
|
||||
.background { box-shadow: $shadow-border; }
|
||||
.background:not(.active) { box-shadow: $shadow-border; }
|
||||
}
|
||||
|
||||
&.always-shadow {
|
||||
.background { box-shadow: $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 {
|
||||
|
|
@ -279,7 +373,7 @@ import {
|
|||
}
|
||||
`,
|
||||
})
|
||||
export class SelectAddComponent implements OnChanges {
|
||||
export class SelectAddComponent {
|
||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||
/** List of string options */
|
||||
readonly items = input<string[]>([]);
|
||||
|
|
@ -309,6 +403,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
// ── 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[] {
|
||||
|
|
@ -320,7 +415,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
protected resolvedSelected(): string | null {
|
||||
// New API: string
|
||||
const s = this.selected();
|
||||
if (s != null) return s;
|
||||
if (s != null && s.trim()) return s;
|
||||
// Legacy API: index into options
|
||||
const idx = this.selectedIndex();
|
||||
const opts = this.resolvedItems();
|
||||
|
|
@ -328,8 +423,18 @@ export class SelectAddComponent implements OnChanges {
|
|||
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: Event): void {
|
||||
event.stopPropagation();
|
||||
this.open.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectItem(item: string): void {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="toggle">
|
||||
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[class.active]="!checked()"
|
||||
(click)="set(false)"
|
||||
(keydown.enter)="set(false)"
|
||||
(keydown.space)="$event.preventDefault(); set(false)"
|
||||
>{{ offLabel() }}</span>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -20,7 +27,14 @@ import {
|
|||
(change)="set(!checked())"
|
||||
/>
|
||||
</label>
|
||||
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[class.active]="checked()"
|
||||
(click)="set(true)"
|
||||
(keydown.enter)="set(true)"
|
||||
(keydown.space)="$event.preventDefault(); set(true)"
|
||||
>{{ onLabel() }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
|
|
@ -30,7 +44,12 @@ import {
|
|||
$size: 30px;
|
||||
|
||||
@include center-child();
|
||||
@include inner-spacing(var(--medium-padding), $horizontal: true);
|
||||
gap: var(--medium-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 100%;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: contents;
|
||||
|
|
@ -41,7 +60,7 @@ import {
|
|||
// 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: 4 * $size;
|
||||
width: var(--toggle-label-width, #{4 * $size});
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--small-padding);
|
||||
line-height: 1.3;
|
||||
|
|
@ -50,10 +69,19 @@ import {
|
|||
&.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;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
export function shouldExpandTasks(keepTasksOpen: boolean, manuallyExpanded: boolean): boolean {
|
||||
return keepTasksOpen || manuallyExpanded;
|
||||
}
|
||||
|
||||
export function taskListMaxHeight(expanded: boolean): string {
|
||||
return expanded ? 'none' : '0px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks accordion — shows pending (not-done) blocks inside a tower.
|
||||
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
|
||||
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses
|
||||
* unless the page setting is keeping tasks open.
|
||||
* Clicking the colored tickbox marks the task done.
|
||||
* Clicking the description opens the block-edit modal via the `edit` output.
|
||||
*/
|
||||
|
|
@ -17,16 +35,29 @@ import { getColorOfTag } from '../../utils/color';
|
|||
<div
|
||||
class="container"
|
||||
[class.show-hover]="pending().length > 0"
|
||||
(click)="expanded.update(v => !v)"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(touchstart)="$event.stopPropagation()"
|
||||
>
|
||||
<p class="header">
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
@if (!initiallyOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="header"
|
||||
(click)="toggleExpanded($event)"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
@if (pending().length === 0) {
|
||||
<span aria-hidden="true"> </span>
|
||||
} @else {
|
||||
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="all-task"
|
||||
#all
|
||||
[style.height.px]="expanded() ? all.scrollHeight : 0"
|
||||
class="all-task"
|
||||
[style.max-height]="taskListMaxHeight(expanded())"
|
||||
>
|
||||
@for (b of pending(); track b.id) {
|
||||
<div class="task-container">
|
||||
|
|
@ -34,13 +65,17 @@ import { getColorOfTag } from '../../utils/color';
|
|||
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
|
||||
<button
|
||||
type="button"
|
||||
class="task-description"
|
||||
[style.color]="colorOf(b.tag)"
|
||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||
>{{ b.description || b.tag }}</p>
|
||||
>{{ b.description || b.tag }}</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -73,26 +108,51 @@ import { getColorOfTag } from '../../utils/color';
|
|||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.header { cursor: pointer; }
|
||||
.header {
|
||||
all: unset;
|
||||
@include medium-text();
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
||||
p { font-size: var(--medium-font-size); }
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.all-task {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
:first-child { margin-top: var(--small-padding); }
|
||||
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
transition: height $long-animation-time;
|
||||
overflow-y: hidden;
|
||||
transition: max-height $long-animation-time;
|
||||
|
||||
/*
|
||||
* Clip while collapsed only. When open, let the outer .container own
|
||||
* scrolling via max-height: 30vh; a nested scroller here pops a
|
||||
* scrollbar the instant a tickbox grows on hover.
|
||||
*/
|
||||
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);
|
||||
|
||||
&:hover p {
|
||||
@media (max-width: $mobile-width) {
|
||||
gap: calc(var(--small-padding) / 2);
|
||||
}
|
||||
|
||||
&:hover .task-description {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
|
@ -102,14 +162,16 @@ import { getColorOfTag } from '../../utils/color';
|
|||
// done without opening the edit carousel. Hover & focus reveal a
|
||||
// subtle inner check mark.
|
||||
.tickbox {
|
||||
flex: 0 0 auto;
|
||||
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(20px);
|
||||
@include square(24px);
|
||||
}
|
||||
border-radius: 4px;
|
||||
box-shadow: $shadow-border;
|
||||
|
|
@ -119,14 +181,25 @@ import { getColorOfTag } from '../../utils/color';
|
|||
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-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
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);
|
||||
transform: translateY(2px);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -138,23 +211,29 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
|
||||
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
.task-description {
|
||||
all: unset;
|
||||
@include medium-text();
|
||||
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: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
|
||||
font-size: var(--medium-font-size);
|
||||
font-weight: 500;
|
||||
filter: saturate(0.85) brightness(0.82);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
&::after { content: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,20 +252,28 @@ export class TasksComponent {
|
|||
/** Emitted when the description is clicked — parent opens the block-edit modal. */
|
||||
readonly edit = output<Block>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private readonly manuallyExpanded = signal(false);
|
||||
readonly expanded = computed(() =>
|
||||
shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()),
|
||||
);
|
||||
readonly taskListMaxHeight = taskListMaxHeight;
|
||||
|
||||
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.
|
||||
// When the page setting switches back to collapsed, discard any older manual
|
||||
// open state so the setting is reflected immediately.
|
||||
effect(() => {
|
||||
const open = this.initiallyOpen();
|
||||
untracked(() => this.expanded.set(open));
|
||||
const keepOpen = this.initiallyOpen();
|
||||
if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
|
||||
});
|
||||
}
|
||||
|
||||
colorOf(tag: string): string {
|
||||
return getColorOfTag(tag, this.baseColor());
|
||||
}
|
||||
|
||||
toggleExpanded(event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (this.initiallyOpen()) return;
|
||||
this.manuallyExpanded.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldExpandTasks, taskListMaxHeight } from './tasks.component';
|
||||
|
||||
describe('shouldExpandTasks', () => {
|
||||
it('expands when tasks should be kept open by page setting', () => {
|
||||
expect(shouldExpandTasks(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('expands when the user manually opens the accordion', () => {
|
||||
expect(shouldExpandTasks(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses when keep-open is disabled', () => {
|
||||
expect(shouldExpandTasks(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskListMaxHeight', () => {
|
||||
it('does not cap open task lists by a measured height', () => {
|
||||
expect(taskListMaxHeight(true)).toBe('none');
|
||||
});
|
||||
|
||||
it('clips collapsed task lists', () => {
|
||||
expect(taskListMaxHeight(false)).toBe('0px');
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,10 @@ import {
|
|||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Tower, Block } from '../../models';
|
||||
import { BlockComponent } from '../block/block.component';
|
||||
|
|
@ -17,67 +21,191 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
|||
import { toCss } from '../../utils/color';
|
||||
|
||||
/** Tracks which entry path the block-edit modal was opened from. */
|
||||
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
|
||||
export interface EditEntry {
|
||||
filter: 'done' | 'pending';
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry {
|
||||
return {
|
||||
filter: keepTasksOpen ? 'pending' : 'done',
|
||||
activeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A done block augmented with per-render animation state. */
|
||||
interface StyledBlock extends Block {
|
||||
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 class="tower">
|
||||
<div class="container" [class.trash-highlight]="trashHighlight()">
|
||||
<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"
|
||||
>
|
||||
<lt-tasks
|
||||
[pending]="pending()"
|
||||
[baseColor]="tower().base_color"
|
||||
[initiallyOpen]="keepTasksOpen()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(markDone)="onMarkTaskDone($event)"
|
||||
(edit)="onEditBlock($event)"
|
||||
/>
|
||||
|
||||
<img
|
||||
src="assets/plus-sign.svg"
|
||||
class="add-block"
|
||||
alt="Add block"
|
||||
(click)="$event.stopPropagation(); openAddBlock()"
|
||||
/>
|
||||
<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 (b of visibleBlocks(); track b.id) {
|
||||
<lt-block
|
||||
[block]="b"
|
||||
[baseColor]="tower().base_color"
|
||||
[class]="b._anim"
|
||||
[style.transform]="b._transform"
|
||||
[style.opacity]="b._opacity"
|
||||
(clicked)="onEditBlock(b)"
|
||||
/>
|
||||
}
|
||||
<div class="block-container-container">
|
||||
<div class="block-container">
|
||||
@for (sq of squares(); track sq.key; let i = $index) {
|
||||
<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"
|
||||
[style.z-index]="squares().length - i"
|
||||
(pointerenter)="onBlockPointerEnter(sq.block.id)"
|
||||
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
|
||||
(clicked)="onEditBlock(sq.block)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
#nameInput
|
||||
type="text"
|
||||
[value]="tower().name"
|
||||
[style.color]="towerNameCss()"
|
||||
(blur)="onRename($event)"
|
||||
(keydown.enter)="nameInput.blur()"
|
||||
/>
|
||||
@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()"
|
||||
|
|
@ -92,7 +220,12 @@ interface StyledBlock extends Block {
|
|||
|
||||
@if (showSettings()) {
|
||||
<lt-modal (close)="showSettings.set(false)">
|
||||
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
|
||||
<lt-tower-settings
|
||||
[tower]="tower()"
|
||||
(save)="onTowerSave($event)"
|
||||
(delete)="onTowerDelete()"
|
||||
(close)="showSettings.set(false)"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
`,
|
||||
|
|
@ -100,7 +233,9 @@ interface StyledBlock extends Block {
|
|||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
min-height: 0;
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
|
|
@ -135,12 +270,12 @@ interface StyledBlock extends Block {
|
|||
transform: scale(0.75);
|
||||
position: relative;
|
||||
|
||||
:before {
|
||||
&::before {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
.tower-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -149,8 +284,10 @@ interface StyledBlock extends Block {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
|
|
@ -158,7 +295,15 @@ interface StyledBlock extends Block {
|
|||
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;
|
||||
|
|
@ -166,9 +311,16 @@ interface StyledBlock extends Block {
|
|||
|
||||
@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 {
|
||||
&::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
|
@ -184,108 +336,206 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
|
||||
lt-tasks {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
min-height: 56px;
|
||||
max-height: 30vh;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
img.add-block {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin: var(--medium-padding) 0;
|
||||
}
|
||||
|
||||
img {
|
||||
.stack-zone {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 48px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 32px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
opacity: 0.33;
|
||||
transition: opacity $long-animation-time;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-end;
|
||||
img.add-block {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
transform: scaleY(-1);
|
||||
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%);
|
||||
}
|
||||
|
||||
/* Default resting position for all blocks before JS sets them */
|
||||
* {
|
||||
transform: translateY(500%);
|
||||
}
|
||||
.block-container-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
.descend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||
opacity 500ms cubic-bezier(0.5, 0, 1, 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);
|
||||
|
||||
.ascend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
font-size: var(--small-font-size);
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
width: 50%;
|
||||
.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 {
|
||||
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 }>();
|
||||
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 };
|
||||
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||
}>();
|
||||
readonly deleteBlock = output<string>();
|
||||
|
||||
|
|
@ -294,10 +544,19 @@ export class TowerComponent {
|
|||
* 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;
|
||||
private destroyed = false;
|
||||
private readonly animationFrames = new Set<number>();
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
|
||||
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));
|
||||
|
|
@ -307,7 +566,14 @@ export class TowerComponent {
|
|||
const entry = this.editEntry();
|
||||
if (!entry) return [];
|
||||
const isDone = entry.filter === 'done';
|
||||
return this.tower().blocks.filter(b => b.is_done === isDone);
|
||||
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. */
|
||||
|
|
@ -326,33 +592,130 @@ export class TowerComponent {
|
|||
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();
|
||||
// Always keep ALL done blocks in the visible list. The date filter
|
||||
// only flips per-block `_anim` so the 1.5s ascend/descend transition
|
||||
// can animate them in and out of the falling stack.
|
||||
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));
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
|
||||
});
|
||||
}
|
||||
|
||||
private reconcile(allDone: Block[], range: { from: number; to: number } | null): void {
|
||||
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.destroyed = true;
|
||||
for (const id of this.animationFrames) cancelAnimationFrame(id);
|
||||
this.animationFrames.clear();
|
||||
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);
|
||||
this.prevDoneIds = allDone.map((b) => b.id);
|
||||
this.isFirstRun = false;
|
||||
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 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);
|
||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||
|
||||
const styled: StyledBlock[] = [];
|
||||
for (const b of allDone) {
|
||||
|
|
@ -379,61 +742,147 @@ export class TowerComponent {
|
|||
});
|
||||
}
|
||||
|
||||
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');
|
||||
if (initialBlocks.length > 0) {
|
||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = styled.find(b => b.id === newId);
|
||||
if (newBlock && inRange(newBlock)) {
|
||||
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||
if (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.
|
||||
newBlock._anim = '';
|
||||
newBlock._transform = 'translateY(500%)';
|
||||
newBlock._opacity = '0';
|
||||
this._visibleBlocks.set(styled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
newBlock._anim = 'descend';
|
||||
newBlock._transform = 'translateY(0)';
|
||||
newBlock._opacity = '1';
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
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(styled);
|
||||
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);
|
||||
this.requestFrame(() => {
|
||||
this.requestFrame(() => {
|
||||
if (this.destroyed) return;
|
||||
this.finishDescendAnimation(blocks);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private requestFrame(callback: () => void): void {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => {
|
||||
this.animationFrames.delete(id);
|
||||
if (!this.destroyed) callback();
|
||||
});
|
||||
this.animationFrames.add(id);
|
||||
}
|
||||
|
||||
private finishDescendAnimation(blocks: StyledBlock[]): void {
|
||||
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) {
|
||||
if (!newName) {
|
||||
input.value = this.tower().name;
|
||||
return;
|
||||
}
|
||||
if (newName !== this.tower().name) {
|
||||
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||
} else {
|
||||
input.value = this.tower().name;
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
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 });
|
||||
this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
|
||||
}
|
||||
|
||||
closeEdit(): void {
|
||||
|
|
@ -442,11 +891,21 @@ export class TowerComponent {
|
|||
|
||||
onBlockSave(ev: BlockEditSave): void {
|
||||
if (ev.id === null) {
|
||||
this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
|
||||
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 },
|
||||
result: {
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -458,7 +917,8 @@ export class TowerComponent {
|
|||
}
|
||||
|
||||
onTowerSave(result: TowerSettingsResult): void {
|
||||
this.showSettings.set(false);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
139
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StyledBlock } from './tower.component';
|
||||
import { editEntryForNewBlock, 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']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editEntryForNewBlock', () => {
|
||||
it('opens the create card in the pending task view when tasks are kept open', () => {
|
||||
expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });
|
||||
});
|
||||
|
||||
it('keeps the existing completed-task default when tasks are collapsed', () => {
|
||||
expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +1,97 @@
|
|||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-welcome',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [A11yModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="welcome-card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close">✕</button>
|
||||
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
|
||||
|
||||
<h2>Welcome to Life Towers</h2>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
aria-label="Dismiss welcome"
|
||||
></button>
|
||||
|
||||
<p class="lead">
|
||||
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
|
||||
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
|
||||
tower as a colored square — the more you do, the taller it grows.
|
||||
<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="muted">
|
||||
Everything you write is saved to a small remote database, keyed to a private UUID token shown
|
||||
under <em>Settings → Account</em>. Copy that token to recover your data on another device, or
|
||||
paste a friend's token to look at theirs.
|
||||
<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 fresh</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
|
||||
<button type="button" (click)="startFresh.emit()">Start empty</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
|
@ -34,18 +100,49 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
|
||||
: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: 66vw;
|
||||
max-width: 480px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
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);
|
||||
|
|
@ -54,29 +151,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
}
|
||||
|
||||
h2 {
|
||||
font-family: $title-font;
|
||||
font-weight: 400;
|
||||
font-size: var(--larger-font-size);
|
||||
text-align: center;
|
||||
margin-bottom: var(--medium-padding);
|
||||
padding: 0 36px;
|
||||
line-height: 1.25;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 28px;
|
||||
}
|
||||
}
|
||||
|
||||
p.lead { color: $text-color; }
|
||||
.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;
|
||||
}
|
||||
|
||||
p.muted {
|
||||
color: rgba($text-color, 0.7);
|
||||
font-size: var(--medium-font-size);
|
||||
em { font-style: italic; }
|
||||
.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,
|
||||
.basic__text {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.basic__label {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.basic__text {
|
||||
color: rgba($text-color, 0.82);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface Block {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
/** How many squares this block draws in the tower (>= 1). */
|
||||
difficulty: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +40,7 @@ export type SaveStatus =
|
|||
| 'saving'
|
||||
| 'saved'
|
||||
| 'retrying'
|
||||
| 'error' // generic / network — will keep trying
|
||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
||||
| 'error' // generic / network — retries exhausted until the next mutation
|
||||
| 'too-large' // 413 — payload exceeds the server cap, will not retry
|
||||
| 'rate-limited' // 429 — will retry after Retry-After
|
||||
| 'invalid'; // 400 — server rejected the body, won't retry
|
||||
| 'invalid'; // 400 — server rejected the body, will not 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +23,9 @@ export class ApiService {
|
|||
);
|
||||
}
|
||||
|
||||
putData(token: string, tree: TreeDto): Promise<void> {
|
||||
return firstValueFrom(
|
||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,44 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ApiService } from './api.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// Mock environment
|
||||
vi.mock('../../environments/environment', () => ({
|
||||
environment: { apiBase: 'http://test-api', production: false },
|
||||
}));
|
||||
describe('ApiService', () => {
|
||||
let service: ApiService;
|
||||
let http: HttpTestingController;
|
||||
|
||||
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');
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), ApiService],
|
||||
});
|
||||
service = TestBed.inject(ApiService);
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
it('constructs correct register URL', () => {
|
||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
||||
afterEach(() => {
|
||||
http.verify();
|
||||
});
|
||||
|
||||
it('constructs correct data URL', () => {
|
||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
||||
it('gets data with a bearer token', async () => {
|
||||
const tree: TreeDto = { pages: [] };
|
||||
const promise = service.getData('token-1');
|
||||
const req = http.expectOne('/api/v1/data');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||
req.flush(tree);
|
||||
await expect(promise).resolves.toEqual(tree);
|
||||
});
|
||||
|
||||
it('formats Authorization header correctly', () => {
|
||||
const token = 'abc-def-123';
|
||||
const header = `Bearer ${token}`;
|
||||
expect(header).toBe('Bearer abc-def-123');
|
||||
it('puts data with a bearer token', async () => {
|
||||
const tree: TreeDto = { pages: [] };
|
||||
const promise = service.putData('token-1', tree);
|
||||
const req = http.expectOne('/api/v1/data');
|
||||
expect(req.request.method).toBe('PUT');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||
expect(req.request.body).toBe(tree);
|
||||
req.flush(null);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Injectable, computed, signal } from '@angular/core';
|
|||
* 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,
|
||||
* (block-edit carousel, settings, tower-settings, confirm-delete,
|
||||
* settings) is on screen — otherwise the user can drag towers from behind
|
||||
* the open card.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { Injectable, inject, signal, 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 CACHE_KEY_PREFIX = 'life-towers.cache.v4';
|
||||
const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4';
|
||||
const DEBOUNCE_MS = 750;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
|
|
@ -28,7 +30,7 @@ function uuidV4(): string {
|
|||
|
||||
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,
|
||||
value.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -49,15 +51,47 @@ function safeSet(key: string, value: string): void {
|
|||
/* ignore */
|
||||
}
|
||||
}
|
||||
function safeRemove(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKeyForToken(token: string): string {
|
||||
return `${CACHE_KEY_PREFIX}.${token}`;
|
||||
}
|
||||
|
||||
function pendingCacheKeyForToken(token: string): string {
|
||||
return `${PENDING_CACHE_KEY_PREFIX}.${token}`;
|
||||
}
|
||||
|
||||
interface PendingPut {
|
||||
token: string;
|
||||
tree: TreeDto;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
|
|
@ -70,26 +104,22 @@ export class StoreService implements OnDestroy {
|
|||
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 retryResolver: (() => void) | null = null;
|
||||
private flushInFlight = false;
|
||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||
private dirtyDuringFlush = false;
|
||||
// Monotonic local mutation version. Prevents an older in-flight PUT from
|
||||
// clearing a newer pending cache entry when it completes.
|
||||
private localMutationRevision = 0;
|
||||
|
||||
// ── 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) {
|
||||
this.switchToken(e.newValue);
|
||||
} else if (e.key === cacheKeyForToken(this._token()) && 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 {
|
||||
|
|
@ -103,17 +133,21 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private initGeneration = 0;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.doInit().finally(() => {
|
||||
this.initPromise = null;
|
||||
const generation = ++this.initGeneration;
|
||||
this.initPromise = this.doInit(generation).finally(() => {
|
||||
if (this.initGeneration === generation) {
|
||||
this.initPromise = null;
|
||||
}
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(): Promise<void> {
|
||||
private async doInit(generation: number): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
// (idempotent — adding the same listener twice is a no-op)
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
|
|
@ -123,6 +157,9 @@ export class StoreService implements OnDestroy {
|
|||
if (stored && !isUuidV4(stored)) {
|
||||
// Garbage in localStorage from a buggy past version — refuse it.
|
||||
stored = null;
|
||||
} else if (stored) {
|
||||
stored = stored.toLowerCase();
|
||||
safeSet(TOKEN_KEY, stored);
|
||||
}
|
||||
const token = stored ?? uuidV4();
|
||||
if (!stored) {
|
||||
|
|
@ -140,7 +177,7 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
try {
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
|
|
@ -148,57 +185,73 @@ export class StoreService implements OnDestroy {
|
|||
try {
|
||||
await this.api.register(token);
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||
} catch {
|
||||
this.loadFromCache();
|
||||
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||
}
|
||||
} else {
|
||||
this.loadFromCache();
|
||||
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||
}
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
if (this.initGeneration === generation) {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrentInit(generation: number, token: string): boolean {
|
||||
return this.initGeneration === generation && this._token() === token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private adoptServerTree(tree: TreeDto, token: string): void {
|
||||
if (safeGet(pendingCacheKeyForToken(token))) {
|
||||
const cachedTree = this.readCachedTree(token);
|
||||
if (cachedTree?.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
const cachedTree = this.readCachedTree(token);
|
||||
if (cachedTree?.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._pages.set(tree.pages);
|
||||
this.updateCache(tree);
|
||||
this.updateCache(token, 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 loadFromCache(token: string): void {
|
||||
const tree = this.readCachedTree(token);
|
||||
if (tree) this._pages.set(tree.pages);
|
||||
}
|
||||
|
||||
private readCachedTree(token: string): TreeDto | null {
|
||||
const raw = safeGet(cacheKeyForToken(token));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as TreeDto;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateCache(tree: TreeDto): void {
|
||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
||||
private updateCache(token: string, tree: TreeDto, pending = false): void {
|
||||
safeSet(cacheKeyForToken(token), JSON.stringify(tree));
|
||||
if (pending) {
|
||||
safeSet(pendingCacheKeyForToken(token), '1');
|
||||
} else {
|
||||
safeRemove(pendingCacheKeyForToken(token));
|
||||
}
|
||||
}
|
||||
|
||||
private cancelPendingWrites(): void {
|
||||
|
|
@ -210,6 +263,11 @@ export class StoreService implements OnDestroy {
|
|||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
if (this.retryResolver !== null) {
|
||||
const resolve = this.retryResolver;
|
||||
this.retryResolver = null;
|
||||
resolve();
|
||||
}
|
||||
this.dirtyDuringFlush = false;
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +284,8 @@ export class StoreService implements OnDestroy {
|
|||
towers: [],
|
||||
};
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackPageCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -242,13 +302,13 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
reorderPages(fromIndex: number, toIndex: number): void {
|
||||
let changed = false;
|
||||
this._pages.update((pages) => {
|
||||
const arr = [...pages];
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
const reordered = reorder(pages, fromIndex, toIndex);
|
||||
changed = reordered !== null;
|
||||
return reordered ?? pages;
|
||||
});
|
||||
this.scheduleSave();
|
||||
if (changed) this.scheduleSave();
|
||||
}
|
||||
|
||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||
|
|
@ -256,6 +316,8 @@ export class StoreService implements OnDestroy {
|
|||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackTowerCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -280,16 +342,17 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||
let changed = false;
|
||||
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);
|
||||
const towers = reorder(p.towers, fromIndex, toIndex);
|
||||
if (towers === null) return p;
|
||||
changed = true;
|
||||
return { ...p, towers };
|
||||
}),
|
||||
);
|
||||
this.scheduleSave();
|
||||
if (changed) this.scheduleSave();
|
||||
}
|
||||
|
||||
addBlock(
|
||||
|
|
@ -298,26 +361,30 @@ export class StoreService implements OnDestroy {
|
|||
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,
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -327,23 +394,29 @@ export class StoreService implements OnDestroy {
|
|||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
let becameDone = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? (() => {
|
||||
const result = this.patchBlockList(t.blocks, blockId, patch);
|
||||
becameDone = result.becameDone;
|
||||
return { ...t, blocks: result.blocks };
|
||||
})()
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
if (becameDone) {
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackBlockCompleted();
|
||||
}
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -352,40 +425,49 @@ export class StoreService implements OnDestroy {
|
|||
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,
|
||||
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 {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) =>
|
||||
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
|
||||
),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -395,19 +477,27 @@ export class StoreService implements OnDestroy {
|
|||
* if the timing flipped).
|
||||
*/
|
||||
switchToken(newToken: string): void {
|
||||
if (!isUuidV4(newToken)) return;
|
||||
const token = newToken.toLowerCase();
|
||||
if (!isUuidV4(token)) return;
|
||||
this.cancelPendingWrites();
|
||||
safeSet(TOKEN_KEY, newToken);
|
||||
this._token.set(newToken);
|
||||
this.initGeneration++;
|
||||
this.initPromise = null;
|
||||
safeSet(TOKEN_KEY, token);
|
||||
this._token.set(token);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this._saveStatus.set('idle');
|
||||
this.localMutationRevision = 0;
|
||||
void this.init();
|
||||
}
|
||||
|
||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSave(): void {
|
||||
this.localMutationRevision += 1;
|
||||
const token = this._token();
|
||||
if (token) this.updateCache(token, { pages: this._pages() }, true);
|
||||
|
||||
if (this.flushInFlight) {
|
||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||
this.dirtyDuringFlush = true;
|
||||
|
|
@ -435,6 +525,7 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
this.flushInFlight = true;
|
||||
this.dirtyDuringFlush = false;
|
||||
const revision = this.localMutationRevision;
|
||||
|
||||
// Cancel any pending retry — runFlush() supersedes it.
|
||||
if (this.retryTimer !== null) {
|
||||
|
|
@ -443,7 +534,7 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
||||
await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0);
|
||||
} finally {
|
||||
this.flushInFlight = false;
|
||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||
|
|
@ -459,7 +550,13 @@ export class StoreService implements OnDestroy {
|
|||
try {
|
||||
await this.api.putData(put.token, put.tree);
|
||||
this._saveStatus.set('saved');
|
||||
this.updateCache(put.tree);
|
||||
if (
|
||||
this._token() === put.token &&
|
||||
put.revision === this.localMutationRevision &&
|
||||
!this.dirtyDuringFlush
|
||||
) {
|
||||
this.updateCache(put.token, put.tree);
|
||||
}
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
|
|
@ -503,8 +600,10 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.retryResolver = resolve;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.retryResolver = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
|
|
@ -512,13 +611,16 @@ export class StoreService implements OnDestroy {
|
|||
// 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);
|
||||
await this.attempt(
|
||||
{ token: put.token, tree: { pages: this._pages() }, revision: put.revision },
|
||||
attempt + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Example data ──────────────────────────────────────────────────────────
|
||||
|
||||
loadExample(): void {
|
||||
loadExample(): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const page: Page = {
|
||||
|
|
@ -529,38 +631,83 @@ export class StoreService implements OnDestroy {
|
|||
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, ageHrs: 0 },
|
||||
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 },
|
||||
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
|
||||
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
|
||||
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
|
||||
{
|
||||
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, ageHrs: 0 },
|
||||
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
|
||||
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
|
||||
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
|
||||
{
|
||||
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, ageHrs: 0 },
|
||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
|
||||
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
|
||||
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
|
||||
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
|
||||
{ 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: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
|
||||
blocks: ExampleBlockSeed[],
|
||||
): Tower {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
|
|
@ -571,11 +718,30 @@ export class StoreService implements OnDestroy {
|
|||
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') {
|
||||
|
|
@ -583,3 +749,22 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reorder<T>(items: readonly T[], fromIndex: number, toIndex: number): T[] | null {
|
||||
if (
|
||||
fromIndex === toIndex ||
|
||||
!Number.isInteger(fromIndex) ||
|
||||
!Number.isInteger(toIndex) ||
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0 ||
|
||||
fromIndex >= items.length ||
|
||||
toIndex >= items.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = [...items];
|
||||
const [item] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { StoreService } from './store.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||
|
|
@ -63,7 +64,10 @@ function makeMockApi(): MockApi {
|
|||
|
||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`;
|
||||
const PENDING_CACHE_KEY = `life-towers.cache-pending.v4.${FIXED_UUID}`;
|
||||
const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function configure(api: MockApi): StoreService {
|
||||
|
|
@ -72,6 +76,18 @@ function configure(api: MockApi): StoreService {
|
|||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: ApiService, useValue: api },
|
||||
{
|
||||
provide: AnalyticsService,
|
||||
useValue: {
|
||||
init: vi.fn(),
|
||||
trackStart: vi.fn(),
|
||||
trackExampleLoaded: vi.fn(),
|
||||
trackPageCreated: vi.fn(),
|
||||
trackTowerCreated: vi.fn(),
|
||||
trackBlockCreated: vi.fn(),
|
||||
trackBlockCompleted: vi.fn(),
|
||||
},
|
||||
},
|
||||
StoreService,
|
||||
],
|
||||
});
|
||||
|
|
@ -137,6 +153,18 @@ describe('StoreService', () => {
|
|||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('canonicalizes a stored uppercase token', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID.toUpperCase();
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||
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();
|
||||
|
|
@ -218,6 +246,98 @@ describe('StoreService', () => {
|
|||
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));
|
||||
const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0);
|
||||
expect(doneSquares).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);
|
||||
const doneSquareCount = tower.blocks
|
||||
.filter((b) => b.is_done)
|
||||
.reduce((sum, block) => sum + block.difficulty, 0);
|
||||
expect(doneSquareCount).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 () => {
|
||||
|
|
@ -233,6 +353,7 @@ describe('StoreService', () => {
|
|||
expect(api.putData).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||
const [, tree] = api.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||
});
|
||||
|
|
@ -263,6 +384,97 @@ describe('StoreService', () => {
|
|||
expect(lastTree.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not let an older in-flight save clear a newer pending cache entry', 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);
|
||||
|
||||
store.addPage('second');
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name))
|
||||
.toEqual(['first', 'second']);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name))
|
||||
.toEqual(['first', 'second']);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a pending local mutation across reload before the debounce saves', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const serverPage = mkPage('server');
|
||||
api.getData.mockResolvedValue({ pages: [serverPage] });
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.updatePage(FIXED_UUID, { keep_tasks_open: true });
|
||||
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
store.ngOnDestroy();
|
||||
|
||||
const reloadedApi = makeMockApi();
|
||||
reloadedApi.getData.mockResolvedValue({ pages: [serverPage] });
|
||||
const reloadedStore = configure(reloadedApi);
|
||||
await reloadedStore.init();
|
||||
|
||||
expect(reloadedStore.pages()[0].keep_tasks_open).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(reloadedApi.putData).toHaveBeenCalledTimes(1);
|
||||
const [, tree] = reloadedApi.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages[0].keep_tasks_open).toBe(true);
|
||||
});
|
||||
|
||||
it('does not let a stale in-flight save clear a newer pending settings cache', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const serverPage = mkPage('server');
|
||||
let resolveFirstPut: (() => void) | null = null;
|
||||
api.getData.mockResolvedValue({ pages: [serverPage] });
|
||||
api.putData
|
||||
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.updatePage(FIXED_UUID, { name: 'renamed' });
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
expect((api.putData.mock.calls[0][1] as TreeDto).pages[0].keep_tasks_open).toBe(false);
|
||||
|
||||
store.updatePage(FIXED_UUID, { keep_tasks_open: true });
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
||||
|
|
@ -350,15 +562,73 @@ describe('StoreService', () => {
|
|||
|
||||
// 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);
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
|
||||
// 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);
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
});
|
||||
|
||||
it('switchToken invalidates an init already in flight', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstGet: ((v: TreeDto) => void) | null = null;
|
||||
api.getData
|
||||
.mockReturnValueOnce(new Promise<TreeDto>((res) => (resolveFirstGet = res)))
|
||||
.mockResolvedValueOnce({ pages: [mkPage('new-account')] });
|
||||
const store = configure(api);
|
||||
|
||||
const firstInit = store.init();
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
resolveFirstGet!({ pages: [mkPage('old-account')] });
|
||||
await firstInit;
|
||||
|
||||
expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN);
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
expect(store.pages()[0].name).toBe('new-account');
|
||||
});
|
||||
|
||||
it('switchToken cancels a pending retry without wedging future saves', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' }))
|
||||
.mockResolvedValue(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('old-account');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
store.addPage('new-account');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN);
|
||||
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account');
|
||||
});
|
||||
|
||||
it('does not load another account cache after switching tokens', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
api.getData.mockRejectedValue(httpError(0));
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(storage[OTHER_CACHE_KEY]).toBeUndefined();
|
||||
expect(store.pages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||
|
|
@ -370,6 +640,19 @@ describe('StoreService', () => {
|
|||
expect(store.token()).toBe(''); // never initialized
|
||||
});
|
||||
|
||||
it('switchToken canonicalizes uppercase UUID input', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.switchToken(OTHER_TOKEN.toUpperCase());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN);
|
||||
});
|
||||
|
||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||
|
||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,5 +16,5 @@ export function hash(s: string): number {
|
|||
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;
|
||||
return h / (2 ** 32 - 2) + 0.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 746 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?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 528.899 528.899" style="enable-background:new 0 0 528.899 528.899;" xml:space="preserve"><g><g>
|
||||
<path d="M328.883,89.125l107.59,107.589l-272.34,272.34L56.604,361.465L328.883,89.125z M518.113,63.177l-47.981-47.981 c-18.543-18.543-48.653-18.543-67.259,0l-45.961,45.961l107.59,107.59l53.611-53.611 C532.495,100.753,532.495,77.559,518.113,63.177z M0.3,512.69c-1.958,8.812,5.998,16.708,14.811,14.565l119.891-29.069 L27.473,390.597L0.3,512.69z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 737 B |
|
|
@ -1,12 +0,0 @@
|
|||
<?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" viewBox="0 0 31.059 31.059" style="enable-background:new 0 0 31.059 31.059;" xml:space="preserve" width="512px" height="512px" class=""><g><g>
|
||||
<g>
|
||||
<path d="M15.529,31.059C6.966,31.059,0,24.092,0,15.529C0,6.966,6.966,0,15.529,0 c8.563,0,15.529,6.966,15.529,15.529C31.059,24.092,24.092,31.059,15.529,31.059z M15.529,1.774 c-7.585,0-13.755,6.171-13.755,13.755s6.17,13.754,13.755,13.754c7.584,0,13.754-6.17,13.754-13.754S23.113,1.774,15.529,1.774z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M21.652,16.416H9.406c-0.49,0-0.888-0.396-0.888-0.887c0-0.49,0.397-0.888,0.888-0.888h12.246 c0.49,0,0.887,0.398,0.887,0.888C22.539,16.02,22.143,16.416,21.652,16.416z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M15.529,22.539c-0.49,0-0.888-0.397-0.888-0.887V9.406c0-0.49,0.398-0.888,0.888-0.888 c0.49,0,0.887,0.398,0.887,0.888v12.246C16.416,22.143,16.02,22.539,15.529,22.539z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,13 +0,0 @@
|
|||
<?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 729.837 729.838" style="enable-background:new 0 0 729.837 729.838;" xml:space="preserve"><g><g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M589.193,222.04c0-6.296,5.106-11.404,11.402-11.404S612,215.767,612,222.04v437.476c0,19.314-7.936,36.896-20.67,49.653 c-12.733,12.734-30.339,20.669-49.653,20.669H188.162c-19.315,0-36.943-7.935-49.654-20.669 c-12.734-12.734-20.669-30.313-20.669-49.653V222.04c0-6.296,5.108-11.404,11.403-11.404c6.296,0,11.404,5.131,11.404,11.404 v437.476c0,13.02,5.37,24.922,13.97,33.521c8.6,8.601,20.503,13.993,33.522,13.993h353.517c13.019,0,24.896-5.394,33.498-13.993 c8.624-8.624,13.992-20.503,13.992-33.498V222.04H589.193z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M279.866,630.056c0,6.296-5.108,11.403-11.404,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.404,5.108,11.404,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M376.323,630.056c0,6.296-5.107,11.403-11.403,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.403,5.108,11.403,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M472.803,630.056c0,6.296-5.106,11.403-11.402,11.403c-6.297,0-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.107-11.404,11.404-11.404c6.296,0,11.402,5.108,11.402,11.404V630.056L472.803,630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M273.214,70.323c0,6.296-5.108,11.404-11.404,11.404c-6.295,0-11.403-5.108-11.403-11.404 c0-19.363,7.911-36.943,20.646-49.677C283.787,7.911,301.368,0,320.73,0h88.379c19.339,0,36.92,7.935,49.652,20.669 c12.734,12.734,20.67,30.362,20.67,49.654c0,6.296-5.107,11.404-11.403,11.404s-11.403-5.108-11.403-11.404 c0-13.019-5.369-24.922-13.97-33.522c-8.602-8.601-20.503-13.994-33.522-13.994h-88.378c-13.043,0-24.922,5.369-33.546,13.97 C278.583,45.401,273.214,57.28,273.214,70.323z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M99.782,103.108h530.273c11.189,0,21.405,4.585,28.818,11.998l0.047,0.048c7.413,7.412,11.998,17.628,11.998,28.818 v29.46c0,6.295-5.108,11.403-11.404,11.403h-0.309H70.323c-6.296,0-11.404-5.108-11.404-11.403v-0.285v-29.175 c0-11.166,4.585-21.406,11.998-28.818l0.048-0.048C78.377,107.694,88.616,103.108,99.782,103.108L99.782,103.108z M630.056,125.916H99.782c-4.965,0-9.503,2.02-12.734,5.274L87,131.238c-3.255,3.23-5.274,7.745-5.274,12.734v18.056h566.361 v-18.056c0-4.965-2.02-9.503-5.273-12.734l-0.049-0.048C639.536,127.936,635.021,125.916,630.056,125.916z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g>
|
||||
</g>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 3 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<?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" viewBox="0 0 212.982 212.982" style="enable-background:new 0 0 212.982 212.982;" xml:space="preserve" width="512px" height="512px" class=""><g><g id="Close">
|
||||
<path d="M131.804,106.491l75.936-75.936c6.99-6.99,6.99-18.323,0-25.312 c-6.99-6.99-18.322-6.99-25.312,0l-75.937,75.937L30.554,5.242c-6.99-6.99-18.322-6.99-25.312,0c-6.989,6.99-6.989,18.323,0,25.312 l75.937,75.936L5.242,182.427c-6.989,6.99-6.989,18.323,0,25.312c6.99,6.99,18.322,6.99,25.312,0l75.937-75.937l75.937,75.937 c6.989,6.99,18.322,6.99,25.312,0c6.99-6.99,6.99-18.322,0-25.312L131.804,106.491z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 816 B |
|
|
@ -4,10 +4,33 @@
|
|||
<meta charset="utf-8" />
|
||||
<title>Life Towers</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5d576b" />
|
||||
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
|
||||
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:title" content="Life Towers" />
|
||||
<meta property="og:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:site_name" content="Life Towers" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta property="og:image:secure_url" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="3333" />
|
||||
<meta property="og:image:height" content="1750" />
|
||||
<meta property="og:image:alt" content="Life Towers task towers preview" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Life Towers" />
|
||||
<meta name="twitter:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta name="twitter:image:alt" content="Life Towers task towers preview" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ input[type='text'] {
|
|||
&:focus {
|
||||
box-shadow: 0 1px $text-color;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 2px $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -56,6 +60,19 @@ button {
|
|||
border-bottom: solid $height #5d576b55;
|
||||
position: relative;
|
||||
|
||||
// Mobile: grow buttons to a ~42px tap target, but pin the label to the bottom
|
||||
// so the bottom-border underline keeps hugging it (as on desktop). A bare
|
||||
// min-height vertically centres the text and strands the underline well below
|
||||
// it. Using flex (not a hit-area pseudo-element) means buttons that `all: unset`
|
||||
// their styling — .tickbox, .swatch, .edit-tower — reset these props and opt out
|
||||
// automatically, instead of inheriting a stray absolute overlay.
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #5d576b55;
|
||||
border-bottom: solid $height #5d576b33;
|
||||
|
|
|
|||
|
|
@ -34,12 +34,31 @@
|
|||
|
||||
@mixin exit {
|
||||
@include square(16px);
|
||||
background: url('/assets/x-sign.svg') no-repeat center center;
|
||||
background-size: 50% 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent url('/assets/x-sign.svg') no-repeat center center / 50% 50%;
|
||||
border: 0 !important;
|
||||
border-bottom: 0 !important;
|
||||
box-shadow: none;
|
||||
box-sizing: content-box;
|
||||
color: transparent;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
text-decoration: none !important;
|
||||
|
||||
@include jump();
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: none !important;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,22 @@
|
|||
margin-bottom: $spacing;
|
||||
}
|
||||
}
|
||||
|
||||
& > lt-modal {
|
||||
@if $horizontal {
|
||||
margin-right: 0 !important;
|
||||
} @else {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed modal hosts should not make the previous content child count as
|
||||
// "not last" for spacing.
|
||||
& > *:not(:last-child):not(lt-modal):not(:has(~ *:not(lt-modal))) {
|
||||
@if $horizontal {
|
||||
margin-right: 0;
|
||||
} @else {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,38 +15,6 @@
|
|||
src: url('assets/fonts/raleway-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url('assets/fonts/material-icons.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@import 'library/main';
|
||||
|
||||
$line-height: 2px;
|
||||
|
|
@ -56,7 +24,7 @@ $line-height: 2px;
|
|||
padding: 0;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@
|
|||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.vitest.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@
|
|||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.vitest.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// The package ships only a `module` field, which Vite's resolver
|
||||
// can't always find — point it at the file directly.
|
||||
'@plausible-analytics/tracker':
|
||||
'@plausible-analytics/tracker/plausible.js',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'],
|
||||
include: ['src/**/*.vitest.ts'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
|
|
|||