Compare commits
17 commits
0ba28b0a14
...
40e2c478fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e2c478fb | |||
| 4fd9e6f6bc | |||
| c2c2598eab | |||
| 8390ece334 | |||
| 108cfb9a19 | |||
| bcca7f4f2e | |||
| e3dcf75eb5 | |||
| 9b8bb96001 | |||
| 85d565ba7b | |||
| d50aa53a73 | |||
| af4216f383 | |||
| ce5f8995f7 | |||
| d9724a462d | |||
| 4156d1d469 | |||
| 757cae5dcf | |||
| 5a364ce638 | |||
| afce46ccf8 |
|
|
@ -8,77 +8,150 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend:
|
backend:
|
||||||
|
name: Backend tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
uses: astral-sh/setup-uv@v4
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
with:
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
version: latest
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: uv python install 3.13
|
||||||
|
|
||||||
- name: Sync dependencies
|
- name: Sync dependencies
|
||||||
working-directory: backend
|
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run pytest
|
||||||
working-directory: backend
|
|
||||||
run: uv run pytest -v
|
run: uv run pytest -v
|
||||||
|
|
||||||
frontend:
|
frontend-lint:
|
||||||
|
name: Frontend lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint (if configured)
|
- name: Run ESLint
|
||||||
working-directory: frontend
|
run: npm run lint
|
||||||
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: Build
|
frontend-test:
|
||||||
working-directory: frontend
|
name: Frontend unit tests
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [backend, frontend]
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Docker image
|
- uses: actions/setup-node@v4
|
||||||
run: docker build -t life-towers:${{ github.sha }} .
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Push to registry
|
- name: Install dependencies
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
run: npm ci
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
- name: Run Vitest
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
run: npm test
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
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: |
|
run: |
|
||||||
if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then
|
set -e
|
||||||
echo "Registry secrets not configured, skipping push"
|
cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers)
|
||||||
exit 0
|
for i in $(seq 1 60); do
|
||||||
fi
|
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
|
||||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin
|
if [ "$status" = healthy ]; then
|
||||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
echo "stack healthy after ${i} attempts"
|
||||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:latest"
|
exit 0
|
||||||
docker push "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
fi
|
||||||
docker push "$REGISTRY_URL/life-towers:latest"
|
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 \
|
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
||||||
LIFE_TOWERS_STATIC_DIR=/app/static \
|
LIFE_TOWERS_STATIC_DIR=/app/static \
|
||||||
|
LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONPATH=/app/src \
|
PYTHONPATH=/app/src \
|
||||||
PATH=/app/.venv/bin:$PATH
|
PATH=/app/.venv/bin:$PATH
|
||||||
|
|
@ -53,4 +54,4 @@ EXPOSE 8000
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
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.
|
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`:
|
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## 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_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_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_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. |
|
| `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
|
## Data persistence
|
||||||
|
|
@ -89,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v
|
||||||
|
|
||||||
## Deployment
|
## 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",
|
"pydantic>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0.0",
|
|
||||||
"pytest-asyncio>=0.23.0",
|
|
||||||
"httpx>=0.27.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""APIRouter with all Life Towers endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
|
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from .auth import get_current_user
|
from .auth import get_current_user
|
||||||
from .db import db_connection
|
from .db import db_connection
|
||||||
from .limits import limiter
|
from .limits import limiter
|
||||||
|
from .logging import token_log_id
|
||||||
from .models import (
|
from .models import (
|
||||||
BlockOut,
|
BlockOut,
|
||||||
DataIn,
|
DataIn,
|
||||||
|
|
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
||||||
(now, token),
|
(now, token),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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)
|
return RegisterResponse(user_id=token)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,7 +93,7 @@ async def get_data(
|
||||||
|
|
||||||
block_rows = conn.execute(
|
block_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, tag, description, is_done, created_at
|
SELECT id, tag, description, is_done, difficulty, created_at
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE tower_id = ?
|
WHERE tower_id = ?
|
||||||
ORDER BY position
|
ORDER BY position
|
||||||
|
|
@ -108,6 +107,7 @@ async def get_data(
|
||||||
tag=b["tag"],
|
tag=b["tag"],
|
||||||
description=b["description"],
|
description=b["description"],
|
||||||
is_done=bool(b["is_done"]),
|
is_done=bool(b["is_done"]),
|
||||||
|
difficulty=b["difficulty"],
|
||||||
created_at=b["created_at"],
|
created_at=b["created_at"],
|
||||||
)
|
)
|
||||||
for b in block_rows
|
for b in block_rows
|
||||||
|
|
@ -213,8 +213,9 @@ async def put_data(
|
||||||
"""
|
"""
|
||||||
INSERT INTO blocks
|
INSERT INTO blocks
|
||||||
(id, tower_id, user_id, position, tag,
|
(id, tower_id, user_id, position, tag,
|
||||||
description, is_done, created_at, updated_at)
|
description, is_done, difficulty,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
block.id,
|
block.id,
|
||||||
|
|
@ -224,6 +225,7 @@ async def put_data(
|
||||||
block.tag,
|
block.tag,
|
||||||
block.description,
|
block.description,
|
||||||
1 if block.is_done else 0,
|
1 if block.is_done else 0,
|
||||||
|
block.difficulty,
|
||||||
created_at,
|
created_at,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
|
|
@ -236,8 +238,14 @@ async def put_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
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:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from .db import db_connection
|
from .db import db_connection
|
||||||
|
from .models import _canonical_uuidv4
|
||||||
|
|
||||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||||
# must not distinguish between missing / malformed / unknown tokens — that
|
# must not distinguish between missing / malformed / unknown tokens — that
|
||||||
|
|
@ -21,25 +20,28 @@ def _unauthorized() -> HTTPException:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> str:
|
def extract_bearer_token(request: Request) -> str | None:
|
||||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
"""Return the raw Bearer token from the Authorization header, or None."""
|
||||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||||
"authorization"
|
"authorization"
|
||||||
)
|
)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
raise _unauthorized()
|
return None
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
raise _unauthorized()
|
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:
|
try:
|
||||||
u = uuid.UUID(token)
|
token = _canonical_uuidv4(token)
|
||||||
if u.version != 4:
|
except ValueError:
|
||||||
raise ValueError("Not v4")
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
raise _unauthorized()
|
raise _unauthorized()
|
||||||
|
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from fastapi import Request, Response
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from .auth import extract_bearer_token
|
||||||
|
|
||||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||||
|
|
||||||
_TOO_LARGE_BODY = json.dumps(
|
_TOO_LARGE_BODY = json.dumps(
|
||||||
|
|
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
|
||||||
|
|
||||||
def _get_token_or_ip(request: Request) -> str:
|
def _get_token_or_ip(request: Request) -> str:
|
||||||
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
||||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
return extract_bearer_token(request) or get_remote_address(request)
|
||||||
if auth:
|
|
||||||
parts = auth.split()
|
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
||||||
return parts[1]
|
|
||||||
return get_remote_address(request)
|
|
||||||
|
|
||||||
|
|
||||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import uuid as _uuid_mod
|
import uuid as _uuid_mod
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from .auth import extract_bearer_token
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure structlog for JSON output."""
|
"""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:
|
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())
|
request_id = str(_uuid_mod.uuid4())
|
||||||
structlog.contextvars.clear_contextvars()
|
structlog.contextvars.clear_contextvars()
|
||||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||||
|
|
||||||
# Extract user_id from Authorization header for logging (no DB call here)
|
# Extract user_id from Authorization header for logging (no DB call here)
|
||||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
token = extract_bearer_token(request)
|
||||||
user_id: str | None = None
|
user_id = token_log_id(token) if token else None
|
||||||
if auth:
|
|
||||||
parts = auth.split()
|
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
||||||
user_id = parts[1]
|
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
"""ASGI app, lifespan, static files mount, route registration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from html import escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 import _rate_limit_exceeded_handler
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
@ -35,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
|
||||||
413: "payload_too_large",
|
413: "payload_too_large",
|
||||||
422: "bad_request",
|
422: "bad_request",
|
||||||
429: "rate_limited",
|
429: "rate_limited",
|
||||||
507: "quota_exceeded",
|
|
||||||
500: "server_error",
|
500: "server_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,10 +90,15 @@ def create_app() -> FastAPI:
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
fields = sorted(
|
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:
|
if fields:
|
||||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
detail_str = "Validation failed for: " + ", ".join(fields)
|
||||||
else:
|
else:
|
||||||
detail_str = "Validation failed"
|
detail_str = "Validation failed"
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -113,7 +116,7 @@ def create_app() -> FastAPI:
|
||||||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||||
detail = {"error": code, "detail": str(exc.detail)}
|
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)
|
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||||
|
|
||||||
# Generic 500 handler
|
# 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)$"
|
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))
|
resp = FileResponse(str(file_path))
|
||||||
if HASHED_PATTERN.search(file_path.name):
|
if HASHED_PATTERN.search(file_path.name):
|
||||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
|
@ -160,7 +200,7 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@app.get("/{full_path:path}", include_in_schema=False)
|
@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);
|
# API routes are handled by the API router (registered before this);
|
||||||
# if execution reaches here for an /api/* path, it really is unknown.
|
# if execution reaches here for an /api/* path, it really is unknown.
|
||||||
if full_path.startswith("api/"):
|
if full_path.startswith("api/"):
|
||||||
|
|
@ -174,12 +214,12 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return _serve_file(candidate)
|
return _serve_file(candidate, request)
|
||||||
|
|
||||||
# SPA fallback to index.html.
|
# SPA fallback to index.html.
|
||||||
index = static_dir / "index.html"
|
index = static_dir / "index.html"
|
||||||
if index.is_file():
|
if index.is_file():
|
||||||
return _serve_file(index)
|
return _serve_file(index, request)
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
-- Life Towers v4 initial schema.
|
-- 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).
|
-- All timestamps are unix epoch seconds (INTEGER).
|
||||||
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
|
@ -17,6 +15,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)),
|
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_from INTEGER,
|
||||||
default_date_to INTEGER,
|
default_date_to INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
|
@ -47,6 +46,7 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||||
tag TEXT NOT NULL DEFAULT '',
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)),
|
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,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
) STRICT;
|
) 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
|
import uuid as _uuid_mod
|
||||||
|
|
||||||
|
|
||||||
def _is_uuidv4(value: str) -> bool:
|
def _canonical_uuidv4(value: str) -> str:
|
||||||
try:
|
try:
|
||||||
u = _uuid_mod.UUID(value)
|
u = _uuid_mod.UUID(value)
|
||||||
return u.version == 4
|
if u.version == 4:
|
||||||
|
return str(u)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return False
|
pass
|
||||||
|
raise ValueError("must be a UUIDv4")
|
||||||
|
|
||||||
|
|
||||||
class HslColor(BaseModel):
|
class HslColor(BaseModel):
|
||||||
|
|
@ -27,14 +29,13 @@ class BlockIn(BaseModel):
|
||||||
tag: str = Field(max_length=200)
|
tag: str = Field(max_length=200)
|
||||||
description: str = Field(max_length=10_000)
|
description: str = Field(max_length=10_000)
|
||||||
is_done: bool
|
is_done: bool
|
||||||
|
difficulty: int = Field(default=1, ge=1, le=100)
|
||||||
created_at: Optional[int] = None
|
created_at: Optional[int] = None
|
||||||
|
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class BlockOut(BaseModel):
|
class BlockOut(BaseModel):
|
||||||
|
|
@ -42,6 +43,7 @@ class BlockOut(BaseModel):
|
||||||
tag: str
|
tag: str
|
||||||
description: str
|
description: str
|
||||||
is_done: bool
|
is_done: bool
|
||||||
|
difficulty: int
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,9 +56,7 @@ class TowerIn(BaseModel):
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class TowerOut(BaseModel):
|
class TowerOut(BaseModel):
|
||||||
|
|
@ -78,9 +78,7 @@ class PageIn(BaseModel):
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class PageOut(BaseModel):
|
class PageOut(BaseModel):
|
||||||
|
|
@ -137,9 +135,7 @@ class RegisterRequest(BaseModel):
|
||||||
@field_validator("token")
|
@field_validator("token")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_token(cls, v: str) -> str:
|
def validate_token(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError("token must be a UUIDv4")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterResponse(BaseModel):
|
class RegisterResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -51,15 +51,71 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
|
||||||
db_module._DB_PATH = None
|
db_module._DB_PATH = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Health
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health(client: AsyncClient) -> None:
|
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||||
resp = await client.get("/api/v1/health")
|
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.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
|
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
|
# Auth / GET /data
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -148,6 +219,7 @@ def _make_tree() -> dict:
|
||||||
"tag": f"tag-{pi}-{ti}-{bi}",
|
"tag": f"tag-{pi}-{ti}-{bi}",
|
||||||
"description": f"desc-{pi}-{ti}-{bi}",
|
"description": f"desc-{pi}-{ti}-{bi}",
|
||||||
"is_done": False,
|
"is_done": False,
|
||||||
|
"difficulty": bi + 1,
|
||||||
"created_at": 1700000000 + bi,
|
"created_at": 1700000000 + bi,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -199,6 +271,42 @@ async def test_round_trip(client: AsyncClient) -> None:
|
||||||
for bi, block in enumerate(tower["blocks"]):
|
for bi, block in enumerate(tower["blocks"]):
|
||||||
assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"]
|
assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"]
|
||||||
assert block["tag"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["tag"]
|
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)
|
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.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
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
11
backend/uv.lock
generated
|
|
@ -201,13 +201,6 @@ dependencies = [
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-asyncio" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|
@ -218,15 +211,11 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.111.0" },
|
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
|
||||||
{ name = "pydantic", specifier = ">=2.0.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 = "slowapi", specifier = ">=0.1.9" },
|
||||||
{ name = "structlog", specifier = ">=24.1.0" },
|
{ name = "structlog", specifier = ">=24.1.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
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.
|
Angular frontend for the Life Towers app. See the root `README.md` for the full
|
||||||
|
stack setup.
|
||||||
## Development server
|
|
||||||
|
|
||||||
To start a local development server, run:
|
|
||||||
|
|
||||||
```bash
|
```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",
|
"packageManager": "npm",
|
||||||
"schematicCollections": [
|
"schematicCollections": [
|
||||||
"angular-eslint"
|
"angular-eslint"
|
||||||
]
|
],
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
|
@ -76,9 +76,6 @@
|
||||||
"proxyConfig": "proxy.conf.json"
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
|
||||||
"builder": "@angular/build:unit-test"
|
|
||||||
},
|
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-eslint/builder:lint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
"options": {
|
"options": {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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 }) => {
|
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Wait for the empty-state hint that means init() completed.
|
// Wait for init, then dismiss the welcome modal so the page controls are reachable.
|
||||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 });
|
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.
|
// Create a page via the select-add dropdown.
|
||||||
await page.locator('lt-select-add .top').first().click();
|
await page.locator('lt-select-add .top').first().click();
|
||||||
|
|
@ -24,7 +44,7 @@ test.describe('Life Towers smoke test', () => {
|
||||||
|
|
||||||
// Create a tower.
|
// Create a tower.
|
||||||
await page.locator('img[alt="Add tower"]').click();
|
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();
|
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||||
|
|
||||||
// Tower's name input is rendered with the tower name as its value.
|
// 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(
|
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
||||||
'Modernise the towers app',
|
'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.
|
// New block is pending → appears in the tasks accordion.
|
||||||
// (Tasks header shows N tasks.)
|
// (Tasks header shows N tasks.)
|
||||||
await expect(page.locator('lt-tasks')).toContainText('1');
|
await expect(page.locator('lt-tasks')).toContainText('1');
|
||||||
|
|
||||||
// Open the tasks accordion + click the task to edit it, then flip done.
|
// 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 .header').click();
|
||||||
await page.locator('lt-tasks .task-container').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(
|
const putLanded = page.waitForResponse(
|
||||||
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
|
(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 .card.active').getByLabel('Already done').check();
|
||||||
await page
|
|
||||||
.locator('lt-block-edit lt-toggle span')
|
|
||||||
.filter({ hasText: 'Goal accomplished' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
|
||||||
await putLanded;
|
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.
|
// Done block now appears as a colored square in the tower's falling stack.
|
||||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
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', {
|
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
|
||||||
timeout: 15000,
|
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();
|
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';
|
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
|
* Visual capture: drives the UI into key states and writes screenshots
|
||||||
* for human review of the legacy-styled design.
|
* for human review of the legacy-styled design.
|
||||||
|
|
@ -7,8 +12,15 @@ import { test } from '@playwright/test';
|
||||||
test.describe('Life Towers visuals', () => {
|
test.describe('Life Towers visuals', () => {
|
||||||
test('capture key UI states', async ({ page }) => {
|
test('capture key UI states', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForSelector('text=Add a new page to get started!', { timeout: 15000 });
|
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||||
await page.screenshot({ path: 'visuals/01-empty-state.png', fullPage: true });
|
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).
|
// Open the page dropdown (without creating a page yet).
|
||||||
await page.locator('lt-select-add .top').first().click();
|
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.locator('img[alt="Add tower"]').click();
|
||||||
await page.waitForSelector('section.modal.active');
|
await page.waitForSelector('section.modal.active');
|
||||||
await page.waitForTimeout(350);
|
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.screenshot({ path: 'visuals/03-new-tower-modal.png', fullPage: true });
|
||||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
|
|
@ -46,20 +58,26 @@ test.describe('Life Towers visuals', () => {
|
||||||
await createCard
|
await createCard
|
||||||
.locator('textarea[placeholder="Write a description here…"]')
|
.locator('textarea[placeholder="Write a description here…"]')
|
||||||
.fill('Finish The Brothers Karamazov');
|
.fill('Finish The Brothers Karamazov');
|
||||||
// Toggle to "Task hasn't been finished yet" so this becomes a pending task.
|
// Uncheck "Already done" so this becomes a pending task.
|
||||||
await createCard
|
await createCard.getByLabel('Already done').uncheck();
|
||||||
.locator('lt-toggle span')
|
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||||
.filter({ hasText: "This task hasn't been finished yet" })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
|
|
||||||
// Open the tasks accordion to show the new tickbox.
|
// Open the tasks accordion to show the new tickbox.
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await page.locator('lt-tasks .container').click();
|
await page.locator('lt-tasks .header').click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
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.
|
// Add a couple more blocks.
|
||||||
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
|
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
|
||||||
await page.locator('img[alt="Add block"]').first().click();
|
await page.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 cc.locator('lt-select-add .top').click();
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
|
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' });
|
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.locator('img[alt="Add tower"]').click();
|
||||||
await page.waitForSelector('section.modal.active');
|
await page.waitForSelector('section.modal.active');
|
||||||
await page.waitForTimeout(350);
|
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.locator('lt-tower-settings button[type="submit"]').click();
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
@ -157,4 +175,39 @@ test.describe('Life Towers visuals', () => {
|
||||||
await page.waitForTimeout(350);
|
await page.waitForTimeout(350);
|
||||||
await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true });
|
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/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.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": {
|
"node_modules/@angular/service-worker": {
|
||||||
"version": "21.2.14",
|
"version": "21.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
||||||
|
|
@ -3519,6 +3501,12 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.60.0",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@
|
||||||
"packageManager": "npm@10.9.2",
|
"packageManager": "npm@10.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.2.13",
|
"@angular/cdk": "^21.2.13",
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.0",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.0",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.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,
|
isDevMode,
|
||||||
provideZonelessChangeDetection,
|
provideZonelessChangeDetection,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
|
||||||
|
|
@ -12,7 +11,6 @@ export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter([]),
|
|
||||||
provideHttpClient(withFetch()),
|
provideHttpClient(withFetch()),
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
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 { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
|
||||||
import { StoreService } from './services/store.service';
|
import { StoreService } from './services/store.service';
|
||||||
|
import { AnalyticsService } from './services/analytics.service';
|
||||||
import { PagesComponent } from './components/pages/pages.component';
|
import { PagesComponent } from './components/pages/pages.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -11,8 +12,10 @@ import { PagesComponent } from './components/pages/pages.component';
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit {
|
||||||
private readonly store = inject(StoreService);
|
private readonly store = inject(StoreService);
|
||||||
|
private readonly analytics = inject(AnalyticsService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.store.init();
|
this.analytics.init();
|
||||||
|
void this.store.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
imports: [],
|
imports: [],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
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: `
|
styles: `
|
||||||
@import '../../../library/main';
|
@import '../../../library/main';
|
||||||
|
|
@ -27,7 +36,16 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
export class BlockComponent {
|
||||||
readonly block = input.required<Block>();
|
readonly block = input.required<Block>();
|
||||||
readonly baseColor = input.required<HslColor>();
|
readonly baseColor = input.required<HslColor>();
|
||||||
|
readonly hovered = input(false);
|
||||||
|
|
||||||
/** Emits when the square is clicked — parent opens the block-edit modal. */
|
/** Emits when the square is clicked — parent opens the block-edit modal. */
|
||||||
readonly clicked = output<void>();
|
readonly clicked = output<void>();
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
inject,
|
|
||||||
signal,
|
signal,
|
||||||
computed,
|
|
||||||
effect,
|
effect,
|
||||||
viewChild,
|
viewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
|
|
@ -15,7 +13,6 @@ import {
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Block, HslColor } from '../../models';
|
import { Block, HslColor } from '../../models';
|
||||||
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
||||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
|
||||||
import { getColorOfTag } from '../../utils/color';
|
import { getColorOfTag } from '../../utils/color';
|
||||||
|
|
||||||
export interface BlockEditSave {
|
export interface BlockEditSave {
|
||||||
|
|
@ -24,25 +21,41 @@ export interface BlockEditSave {
|
||||||
tag: string;
|
tag: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_done: boolean;
|
is_done: boolean;
|
||||||
|
difficulty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditedValue {
|
interface EditedValue {
|
||||||
tag: string;
|
tag: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_done: boolean;
|
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({
|
@Component({
|
||||||
selector: 'lt-block-edit',
|
selector: 'lt-block-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SelectAddComponent, ToggleComponent],
|
imports: [SelectAddComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
|
@if (viewTitle()) {
|
||||||
|
<h2 class="view-title">{{ viewTitle() }}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
#container
|
#container
|
||||||
class="carousel"
|
class="carousel"
|
||||||
(scroll)="onScroll()"
|
(scroll)="onScroll()"
|
||||||
(click)="onBackdropClick($event)"
|
(click)="onBackdropClick($event)"
|
||||||
|
(keydown.enter)="onBackdropClick($any($event))"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="card placeholder"></div>
|
<div class="card placeholder"></div>
|
||||||
|
|
||||||
|
|
@ -51,17 +64,27 @@ interface EditedValue {
|
||||||
class="card"
|
class="card"
|
||||||
[class.active]="activeIdx() === i + 1"
|
[class.active]="activeIdx() === i + 1"
|
||||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
[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)"
|
(click)="onCardClick(i + 1)"
|
||||||
|
(keydown.enter)="onCardClick(i + 1)"
|
||||||
|
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||||
>
|
>
|
||||||
<div class="mask"></div>
|
<div class="mask"></div>
|
||||||
|
|
||||||
<div class="header">
|
<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
|
<div
|
||||||
class="block-dot"
|
class="block-dot"
|
||||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||||
></div>
|
></div>
|
||||||
<h1>{{ formatDate(b.created_at) }}</h1>
|
<h1>{{ formatDate(b.created_at, true) }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="select-add-container">
|
<div class="select-add-container">
|
||||||
|
|
@ -70,7 +93,7 @@ interface EditedValue {
|
||||||
[selected]="editedFor(b.id).tag"
|
[selected]="editedFor(b.id).tag"
|
||||||
[alwaysDropShadow]="true"
|
[alwaysDropShadow]="true"
|
||||||
[onlyShadowBorder]="true"
|
[onlyShadowBorder]="true"
|
||||||
placeholder="Tag this item…"
|
[placeholder]="tagPlaceholder('Tag this item…')"
|
||||||
(select)="updateTag(b.id, $event)"
|
(select)="updateTag(b.id, $event)"
|
||||||
(add)="updateTag(b.id, $event)"
|
(add)="updateTag(b.id, $event)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -78,18 +101,40 @@ interface EditedValue {
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Write a description here…"
|
placeholder="Write a description here…"
|
||||||
|
maxlength="10000"
|
||||||
[value]="editedFor(b.id).description"
|
[value]="editedFor(b.id).description"
|
||||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||||
(blur)="flushExisting(b.id)"
|
(blur)="flushExisting(b.id)"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div>
|
<label class="done-checkbox">
|
||||||
<lt-toggle
|
<input
|
||||||
|
type="checkbox"
|
||||||
[checked]="editedFor(b.id).is_done"
|
[checked]="editedFor(b.id).is_done"
|
||||||
(checkedChange)="updateDone(b.id, $event)"
|
(change)="updateDone(b.id, $any($event.target).checked)"
|
||||||
offLabel="This task hasn't been finished yet"
|
|
||||||
onLabel="Goal already accomplished"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
|
|
@ -102,12 +147,22 @@ interface EditedValue {
|
||||||
class="card create-card"
|
class="card create-card"
|
||||||
[class.active]="activeIdx() === blocks().length + 1"
|
[class.active]="activeIdx() === blocks().length + 1"
|
||||||
[class.near-active]="activeIdx() === blocks().length"
|
[class.near-active]="activeIdx() === blocks().length"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Focus create card"
|
||||||
(click)="onCardClick(blocks().length + 1)"
|
(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="mask"></div>
|
||||||
|
|
||||||
<div class="header">
|
<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
|
<div
|
||||||
class="block-dot"
|
class="block-dot"
|
||||||
[style.background-color]="colorOfNewTag()"
|
[style.background-color]="colorOfNewTag()"
|
||||||
|
|
@ -115,13 +170,13 @@ interface EditedValue {
|
||||||
<h1>Create now</h1>
|
<h1>Create now</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="select-add-container">
|
<div class="select-add-container" [class.required-empty]="!newValue().tag">
|
||||||
<lt-select-add
|
<lt-select-add
|
||||||
[items]="tags()"
|
[items]="tags()"
|
||||||
[selected]="newValue().tag"
|
[selected]="newValue().tag"
|
||||||
[alwaysDropShadow]="true"
|
[alwaysDropShadow]="true"
|
||||||
[onlyShadowBorder]="true"
|
[onlyShadowBorder]="true"
|
||||||
placeholder="Set a category…"
|
[placeholder]="tagPlaceholder('Set a category…')"
|
||||||
(select)="updateNewTag($event)"
|
(select)="updateNewTag($event)"
|
||||||
(add)="updateNewTag($event)"
|
(add)="updateNewTag($event)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -129,17 +184,39 @@ interface EditedValue {
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Write a description here…"
|
placeholder="Write a description here…"
|
||||||
|
maxlength="10000"
|
||||||
[value]="newValue().description"
|
[value]="newValue().description"
|
||||||
(input)="updateNewDescription($any($event.target).value)"
|
(input)="updateNewDescription($any($event.target).value)"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div>
|
<label class="done-checkbox">
|
||||||
<lt-toggle
|
<input
|
||||||
|
type="checkbox"
|
||||||
[checked]="newValue().is_done"
|
[checked]="newValue().is_done"
|
||||||
(checkedChange)="updateNewDone($event)"
|
(change)="updateNewDone($any($event.target).checked)"
|
||||||
offLabel="This task hasn't been finished yet"
|
|
||||||
onLabel="Goal already accomplished"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
|
|
@ -166,17 +243,51 @@ interface EditedValue {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 10001; // above modal backdrop (10000)
|
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 {
|
.carousel {
|
||||||
|
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
padding: var(--title-clearance) 0 var(--large-padding);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
scroll-behavior: smooth;
|
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 {
|
&::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
@ -192,8 +303,12 @@ interface EditedValue {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
scroll-snap-align: center;
|
||||||
@media (max-width: $mobile-width) {
|
@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;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -246,6 +361,11 @@ interface EditedValue {
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
width: 60vw;
|
width: 60vw;
|
||||||
max-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;
|
box-shadow: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
@ -253,10 +373,16 @@ interface EditedValue {
|
||||||
.header {
|
.header {
|
||||||
@include center-child();
|
@include center-child();
|
||||||
position: relative;
|
position: relative;
|
||||||
|
gap: var(--small-padding);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.exit {
|
.exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
right: 0;
|
||||||
@include exit();
|
@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 {
|
.bottom {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
|
|
@ -291,10 +543,29 @@ interface EditedValue {
|
||||||
transform: translateY(-50%) translateX(-50%);
|
transform: translateY(-50%) translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
lt-select-add,
|
||||||
|
.done-checkbox {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
min-height: 42px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class BlockEditComponent implements AfterViewInit {
|
export class BlockEditComponent implements AfterViewInit {
|
||||||
|
readonly viewTitle = input<string>('');
|
||||||
readonly blocks = input.required<Block[]>();
|
readonly blocks = input.required<Block[]>();
|
||||||
readonly activeBlockId = input<string | null>(null);
|
readonly activeBlockId = input<string | null>(null);
|
||||||
readonly tags = input<string[]>([]);
|
readonly tags = input<string[]>([]);
|
||||||
|
|
@ -317,7 +588,9 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '',
|
description: '',
|
||||||
is_done: true,
|
is_done: true,
|
||||||
|
difficulty: 1,
|
||||||
});
|
});
|
||||||
|
private newDoneEdited = false;
|
||||||
|
|
||||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||||
readonly activeIdx = signal(1);
|
readonly activeIdx = signal(1);
|
||||||
|
|
@ -334,6 +607,7 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
tag: b.tag,
|
tag: b.tag,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
is_done: b.is_done,
|
is_done: b.is_done,
|
||||||
|
difficulty: b.difficulty ?? 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
untracked(() => this.editedValues.set(m));
|
untracked(() => this.editedValues.set(m));
|
||||||
|
|
@ -344,13 +618,24 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
const t = this.tags();
|
const t = this.tags();
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
const cur = this.newValue();
|
const cur = this.newValue();
|
||||||
if (!cur.tag && t.length > 0) {
|
if (!cur.tag) {
|
||||||
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
|
this.newValue.set({
|
||||||
} else if (!cur.tag) {
|
...cur,
|
||||||
this.newValue.set({ ...cur, is_done: this.defaultDone() });
|
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 {
|
ngAfterViewInit(): void {
|
||||||
|
|
@ -373,26 +658,44 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '',
|
description: '',
|
||||||
is_done: false,
|
is_done: false,
|
||||||
|
difficulty: 1,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
colorOfTagForBlock(id: string): string {
|
private colorOfTag(tag: string): string {
|
||||||
const v = this.editedFor(id);
|
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
|
||||||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
colorOfNewTag = computed(() => {
|
colorOfTagForBlock(id: string): string {
|
||||||
const t = this.newValue().tag;
|
return this.colorOfTag(this.editedFor(id).tag);
|
||||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
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',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,6 +716,12 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
this.flushExisting(id);
|
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 {
|
private patchEdited(id: string, patch: Partial<EditedValue>): void {
|
||||||
this.editedValues.update((m) => {
|
this.editedValues.update((m) => {
|
||||||
const v = m.get(id);
|
const v = m.get(id);
|
||||||
|
|
@ -426,7 +735,13 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
flushExisting(id: string): void {
|
flushExisting(id: string): void {
|
||||||
const v = this.editedFor(id);
|
const v = this.editedFor(id);
|
||||||
if (!v.tag) return; // Skip empty saves
|
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 {
|
onDelete(id: string): void {
|
||||||
|
|
@ -444,9 +759,14 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNewDone(is_done: boolean): void {
|
updateNewDone(is_done: boolean): void {
|
||||||
|
this.newDoneEdited = true;
|
||||||
this.newValue.update((v) => ({ ...v, is_done }));
|
this.newValue.update((v) => ({ ...v, is_done }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNewDifficulty(delta: number): void {
|
||||||
|
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||||
|
}
|
||||||
|
|
||||||
submitNew(): void {
|
submitNew(): void {
|
||||||
const v = this.newValue();
|
const v = this.newValue();
|
||||||
if (!v.tag) return;
|
if (!v.tag) return;
|
||||||
|
|
@ -455,6 +775,7 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
tag: v.tag,
|
tag: v.tag,
|
||||||
description: v.description,
|
description: v.description,
|
||||||
is_done: v.is_done,
|
is_done: v.is_done,
|
||||||
|
difficulty: v.difficulty,
|
||||||
});
|
});
|
||||||
this.close.emit();
|
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,
|
Component,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
output,
|
output,
|
||||||
|
input,
|
||||||
signal,
|
signal,
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
|
@ -22,8 +23,20 @@ import { ModalStateService } from '../../services/modal-state.service';
|
||||||
class="modal"
|
class="modal"
|
||||||
[class.active]="active()"
|
[class.active]="active()"
|
||||||
(click)="onBackdropClick($event)"
|
(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>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -31,19 +44,35 @@ import { ModalStateService } from '../../services/modal-state.service';
|
||||||
styles: `
|
styles: `
|
||||||
@import '../../../library/main';
|
@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;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
|
display: block;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.modal {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
@include center-child();
|
@include center-child();
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: $background-gradient;
|
background: rgba(255, 248, 248, 0.94);
|
||||||
transition: opacity 300ms;
|
transition: opacity 300ms;
|
||||||
opacity: 1;
|
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) {
|
&:not(.active) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -57,6 +86,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ModalComponent implements AfterViewInit, OnDestroy {
|
export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||||
|
readonly labelledBy = input<string | null>(null);
|
||||||
|
readonly describedBy = input<string | null>(null);
|
||||||
readonly close = output<void>();
|
readonly close = output<void>();
|
||||||
|
|
||||||
// The active signal starts false; AfterViewInit flips it true on next tick
|
// 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 readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||||
private previousFocus: HTMLElement | null = null;
|
private previousFocus: HTMLElement | null = null;
|
||||||
private escListener!: (e: KeyboardEvent) => void;
|
|
||||||
private readonly modalState = inject(ModalStateService);
|
private readonly modalState = inject(ModalStateService);
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
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;
|
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({
|
@Component({
|
||||||
selector: 'lt-settings',
|
selector: 'lt-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -26,7 +23,7 @@ const UUIDV4_RE =
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="card">
|
<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>
|
<h2>Settings</h2>
|
||||||
|
|
||||||
@if (page()) {
|
@if (page()) {
|
||||||
|
|
@ -36,26 +33,30 @@ const UUIDV4_RE =
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[value]="pageName()"
|
[value]="pageName()"
|
||||||
(blur)="onRenamePage($any($event.target).value)"
|
(blur)="onRenamePage($any($event.target))"
|
||||||
placeholder="Page name…"
|
placeholder="Page name…"
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
aria-label="Page name"
|
aria-label="Page name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<lt-toggle
|
<div class="toggle-list">
|
||||||
[checked]="hideCreateTowerButton()"
|
<lt-toggle
|
||||||
(checkedChange)="onHideCreateTowerButtonChange($event)"
|
class="setting-toggle"
|
||||||
offLabel="Show add-tower button"
|
[checked]="hideCreateTowerButton()"
|
||||||
onLabel="Hide add-tower button"
|
(checkedChange)="onHideCreateTowerButtonChange($event)"
|
||||||
/>
|
offLabel="Show add-tower button"
|
||||||
|
onLabel="Hide add-tower button"
|
||||||
|
/>
|
||||||
|
|
||||||
<lt-toggle
|
<lt-toggle
|
||||||
[checked]="keepTasksOpen()"
|
class="setting-toggle"
|
||||||
(checkedChange)="onKeepTasksOpenChange($event)"
|
[checked]="keepTasksOpen()"
|
||||||
offLabel="Show tasks collapsed"
|
(checkedChange)="onKeepTasksOpenChange($event)"
|
||||||
onLabel="Keep tasks open"
|
offLabel="Show tasks collapsed"
|
||||||
/>
|
onLabel="Keep tasks open"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="danger" type="button" (click)="deletePage.emit()">
|
<button class="danger" type="button" (click)="deletePage.emit()">
|
||||||
Delete this page
|
Delete this page
|
||||||
|
|
@ -68,7 +69,7 @@ const UUIDV4_RE =
|
||||||
<section class="account-section">
|
<section class="account-section">
|
||||||
<h3>Account</h3>
|
<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">
|
<div class="token-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -111,7 +112,11 @@ const UUIDV4_RE =
|
||||||
@include card();
|
@include card();
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 480px;
|
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;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -123,17 +128,19 @@ const UUIDV4_RE =
|
||||||
top: var(--medium-padding);
|
top: var(--medium-padding);
|
||||||
right: var(--medium-padding);
|
right: var(--medium-padding);
|
||||||
@include exit();
|
@include exit();
|
||||||
font-size: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 var(--large-padding) 0;
|
margin: 0 0 var(--large-padding) 0;
|
||||||
|
padding: 0 36px;
|
||||||
|
line-height: 1.3;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 var(--medium-padding) 0;
|
margin: 0 0 var(--medium-padding) 0;
|
||||||
font-size: var(--large-font-size);
|
font-size: var(--medium-font-size);
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
|
|
@ -151,8 +158,37 @@ const UUIDV4_RE =
|
||||||
margin: var(--large-padding) 0;
|
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 {
|
.hint {
|
||||||
font-size: var(--small-font-size);
|
font-size: var(--medium-font-size);
|
||||||
|
line-height: 1.35;
|
||||||
color: rgba($text-color, 0.7);
|
color: rgba($text-color, 0.7);
|
||||||
margin: 0 0 4px 0;
|
margin: 0 0 4px 0;
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +207,13 @@ const UUIDV4_RE =
|
||||||
button {
|
button {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 0 0 auto;
|
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 hideCreateTowerButton = signal(false);
|
||||||
readonly keepTasksOpen = 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
|
// Token-switch state
|
||||||
readonly tokenInput = signal('');
|
readonly tokenInput = signal('');
|
||||||
readonly tokenInputTouched = signal(false);
|
readonly tokenInputTouched = signal(false);
|
||||||
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
|
readonly isValidToken = computed(() =>
|
||||||
|
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
|
@ -225,10 +273,14 @@ export class SettingsComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onRenamePage(value: string): void {
|
onRenamePage(input: HTMLInputElement): void {
|
||||||
const trimmed = value.trim();
|
const trimmed = input.value.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) {
|
||||||
|
input.value = this.pageName();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.pageName.set(trimmed);
|
this.pageName.set(trimmed);
|
||||||
|
input.value = trimmed;
|
||||||
this.flushPageUpdate();
|
this.flushPageUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import {
|
||||||
output,
|
output,
|
||||||
OnInit,
|
OnInit,
|
||||||
inject,
|
inject,
|
||||||
|
DestroyRef,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { Tower, HslColor } from '../../models';
|
import { Tower, HslColor } from '../../models';
|
||||||
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
|
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
|
||||||
|
|
@ -21,10 +23,7 @@ export interface TowerSettingsResult {
|
||||||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="header">
|
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
|
||||||
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
<input
|
<input
|
||||||
|
|
@ -32,19 +31,22 @@ export interface TowerSettingsResult {
|
||||||
name="towerName"
|
name="towerName"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Tower name…"
|
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
class="title-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
<div class="picker-row">
|
||||||
|
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
||||||
<button type="submit" [disabled]="form.invalid">
|
</div>
|
||||||
{{ tower() ? 'Save' : 'Create tower' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (tower()) {
|
@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>
|
<button type="button" (click)="delete.emit()">Delete tower</button>
|
||||||
|
} @else {
|
||||||
|
<button type="submit" [disabled]="form.invalid">Create tower</button>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
|
|
@ -55,30 +57,45 @@ export interface TowerSettingsResult {
|
||||||
@include card();
|
@include card();
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 400px;
|
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;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
|
padding-top: calc(var(--large-padding) + var(--medium-padding));
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: $shadow;
|
box-shadow: $shadow;
|
||||||
@include inner-spacing(var(--large-padding));
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.header {
|
.exit {
|
||||||
@include center-child();
|
position: absolute;
|
||||||
|
top: var(--medium-padding);
|
||||||
.exit {
|
right: var(--medium-padding);
|
||||||
position: absolute;
|
@include exit();
|
||||||
left: var(--large-padding);
|
|
||||||
@include exit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'] {
|
form {
|
||||||
|
@include inner-spacing(var(--large-padding));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
@include title-text();
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: block;
|
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>();
|
readonly close = output<void>();
|
||||||
|
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
form = this.fb.group({
|
form = this.fb.group({
|
||||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||||
|
|
@ -102,17 +120,38 @@ export class TowerSettingsComponent implements OnInit {
|
||||||
if (t) {
|
if (t) {
|
||||||
this.form.patchValue({ name: t.name });
|
this.form.patchValue({ name: t.name });
|
||||||
this.currentColor = { ...t.base_color };
|
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 {
|
onColorChange(color: HslColor): void {
|
||||||
this.currentColor = color;
|
this.currentColor = color;
|
||||||
|
// In edit mode the picker is a live control — commit each change.
|
||||||
|
if (this.tower()) this.autoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
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;
|
if (this.form.invalid) return;
|
||||||
const v = this.form.value;
|
this.emitSave();
|
||||||
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
|
}
|
||||||
|
|
||||||
|
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) {
|
@for (tower of page().towers; track tower.id) {
|
||||||
<lt-tower
|
<lt-tower
|
||||||
cdkDrag
|
cdkDrag
|
||||||
[cdkDragDisabled]="modalOpen()"
|
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
||||||
[tower]="tower"
|
[tower]="tower"
|
||||||
[dateRange]="dateRange()"
|
[dateRange]="dateRange()"
|
||||||
[keepTasksOpen]="page().keep_tasks_open"
|
[keepTasksOpen]="page().keep_tasks_open"
|
||||||
|
[animateInitialStack]="animateInitialStack()"
|
||||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||||
|
|
@ -21,7 +22,16 @@
|
||||||
}
|
}
|
||||||
@if (!page().hide_create_tower_button) {
|
@if (!page().hide_create_tower_button) {
|
||||||
<div class="add-tower-wrapper">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -48,7 +58,7 @@
|
||||||
|
|
||||||
@if (showAddTower()) {
|
@if (showAddTower()) {
|
||||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
<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>
|
</lt-modal>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +66,7 @@
|
||||||
<lt-modal (close)="cancelTowerDelete()">
|
<lt-modal (close)="cancelTowerDelete()">
|
||||||
<div class="confirm-delete">
|
<div class="confirm-delete">
|
||||||
<div class="header">
|
<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>
|
<h2>Delete tower</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
<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 {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
position: relative; // anchor for absolute-positioned .trash
|
position: relative; // anchor for absolute-positioned .trash
|
||||||
|
|
||||||
@include inner-spacing(var(--large-padding));
|
@include inner-spacing(var(--large-padding));
|
||||||
|
|
@ -18,14 +19,17 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
flex: 1 0 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
transition: box-shadow $short-animation-time;
|
transition: box-shadow $short-animation-time;
|
||||||
|
|
||||||
max-width: 800px;
|
max-width: 100%;
|
||||||
|
gap: var(--medium-padding);
|
||||||
|
|
||||||
&.cdk-drop-list-dragging {
|
&.cdk-drop-list-dragging {
|
||||||
*:not(.cdk-drag-placeholder) {
|
*:not(.cdk-drag-placeholder) {
|
||||||
|
|
@ -52,28 +56,45 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
|
width: 100%;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
box-sizing: content-box;
|
box-sizing: border-box;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
&:not(:nth-last-child(1)) {
|
min-height: 0;
|
||||||
margin-right: var(--medium-padding);
|
|
||||||
@media (max-width: $mobile-width) {
|
|
||||||
margin-right: var(--small-padding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@for $i from 1 to 12 {
|
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
|
||||||
& > *:first-child:nth-last-child(#{$i}),
|
@media (max-width: $mobile-width) {
|
||||||
& > *:first-child:nth-last-child(#{$i}) ~ * {
|
--mobile-tower-width: calc(66vw - var(--small-padding));
|
||||||
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
|
--mobile-tower-side-padding: max(
|
||||||
|
var(--medium-padding),
|
||||||
|
calc((100% - var(--mobile-tower-width)) / 2)
|
||||||
|
);
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
overflow-x: auto;
|
||||||
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
|
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();
|
@include card();
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 500px;
|
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;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -101,7 +126,7 @@
|
||||||
@include center-child();
|
@include center-child();
|
||||||
.exit {
|
.exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--large-padding);
|
right: var(--large-padding);
|
||||||
@include exit();
|
@include exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,11 +140,21 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--large-padding);
|
gap: var(--large-padding);
|
||||||
|
|
||||||
|
button {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
button.danger {
|
button.danger {
|
||||||
color: #b53f3f;
|
color: #b53f3f;
|
||||||
border-bottom-color: #b53f3f55;
|
border-bottom-color: #b53f3f55;
|
||||||
&:after { background-color: #b53f3f; }
|
&: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,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
HostListener,
|
||||||
|
effect,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Page } from '../../models';
|
import { Page } from '../../models';
|
||||||
import { StoreService } from '../../services/store.service';
|
import { StoreService } from '../../services/store.service';
|
||||||
|
|
@ -15,7 +19,6 @@ import {
|
||||||
DoubleSliderComponent,
|
DoubleSliderComponent,
|
||||||
DoubleSliderRange,
|
DoubleSliderRange,
|
||||||
} from '../shared/double-slider/double-slider.component';
|
} from '../shared/double-slider/double-slider.component';
|
||||||
import { computed } from '@angular/core';
|
|
||||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||||
import { ModalStateService } from '../../services/modal-state.service';
|
import { ModalStateService } from '../../services/modal-state.service';
|
||||||
|
|
||||||
|
|
@ -38,6 +41,7 @@ interface BlockPatch {
|
||||||
tag: string;
|
tag: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_done: boolean;
|
is_done: boolean;
|
||||||
|
difficulty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimum blocks before the date-range slider becomes visible. */
|
/** Minimum blocks before the date-range slider becomes visible. */
|
||||||
|
|
@ -60,12 +64,14 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
|
||||||
})
|
})
|
||||||
export class PageComponent {
|
export class PageComponent {
|
||||||
readonly page = input.required<Page>();
|
readonly page = input.required<Page>();
|
||||||
|
readonly animateInitialStack = input<boolean>(false);
|
||||||
readonly dragHappening = output<boolean>();
|
readonly dragHappening = output<boolean>();
|
||||||
|
|
||||||
protected readonly store = inject(StoreService);
|
protected readonly store = inject(StoreService);
|
||||||
private readonly modalState = inject(ModalStateService);
|
private readonly modalState = inject(ModalStateService);
|
||||||
/** True while any lt-modal is mounted — used to lock tower drag. */
|
/** True while any lt-modal is mounted — used to lock tower drag. */
|
||||||
readonly modalOpen = this.modalState.anyOpen;
|
readonly modalOpen = this.modalState.anyOpen;
|
||||||
|
readonly mobileDragDisabled = signal(this.isMobileViewport());
|
||||||
|
|
||||||
readonly showAddTower = signal(false);
|
readonly showAddTower = signal(false);
|
||||||
readonly isDragging = signal(false);
|
readonly isDragging = signal(false);
|
||||||
|
|
@ -112,10 +118,30 @@ export class PageComponent {
|
||||||
/** Selected date range — `null` = show everything. */
|
/** Selected date range — `null` = show everything. */
|
||||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (!this.showSlider()) {
|
||||||
|
untracked(() => this.dateRange.set(null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
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 ────────────────────────────────────────────────────────
|
// ── Tower mutations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onAddTower(result: TowerSettingsResult): void {
|
onAddTower(result: TowerSettingsResult): void {
|
||||||
|
|
@ -131,7 +157,7 @@ export class PageComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeleteTower(towerId: string): void {
|
onDeleteTower(towerId: string): void {
|
||||||
this.store.deleteTower(this.page().id, towerId);
|
this.confirmDeleteTowerId.set(towerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Block mutations ────────────────────────────────────────────────────────
|
// ── Block mutations ────────────────────────────────────────────────────────
|
||||||
|
|
@ -143,6 +169,7 @@ export class PageComponent {
|
||||||
result.tag,
|
result.tag,
|
||||||
result.description,
|
result.description,
|
||||||
result.is_done,
|
result.is_done,
|
||||||
|
result.difficulty,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,13 +214,16 @@ export class PageComponent {
|
||||||
|
|
||||||
onTrashEnter(): void {
|
onTrashEnter(): void {
|
||||||
this.nearTrashcan = true;
|
this.nearTrashcan = true;
|
||||||
const preview = document.querySelector('.cdk-drag-preview');
|
this.dragPreview()?.classList.add('trash-highlight');
|
||||||
if (preview) preview.classList.add('trash-highlight');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onTrashLeave(): void {
|
onTrashLeave(): void {
|
||||||
this.nearTrashcan = false;
|
this.nearTrashcan = false;
|
||||||
const preview = document.querySelector('.cdk-drag-preview');
|
this.dragPreview()?.classList.remove('trash-highlight');
|
||||||
if (preview) preview.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">
|
<div class="page-container">
|
||||||
@if (selectedPage(); as page) {
|
@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 {
|
} @else {
|
||||||
<p>Add a new page to get started!</p>
|
<p>Add a new page to get started!</p>
|
||||||
}
|
}
|
||||||
|
|
@ -27,14 +31,37 @@
|
||||||
[page]="selectedPage()"
|
[page]="selectedPage()"
|
||||||
(close)="showSettings.set(false)"
|
(close)="showSettings.set(false)"
|
||||||
(updatePage)="onUpdatePage($event)"
|
(updatePage)="onUpdatePage($event)"
|
||||||
(deletePage)="onRemovePage()"
|
(deletePage)="onRequestRemovePage()"
|
||||||
(switchAccount)="onSwitchAccount($event)"
|
(switchAccount)="onSwitchAccount($event)"
|
||||||
/>
|
/>
|
||||||
</lt-modal>
|
</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()) {
|
@if (showWelcome()) {
|
||||||
<lt-modal (close)="showWelcome.set(false)">
|
<lt-modal
|
||||||
|
labelledBy="welcome-title"
|
||||||
|
describedBy="welcome-description"
|
||||||
|
(close)="showWelcome.set(false)"
|
||||||
|
>
|
||||||
<lt-welcome
|
<lt-welcome
|
||||||
(close)="showWelcome.set(false)"
|
(close)="showWelcome.set(false)"
|
||||||
(startFresh)="showWelcome.set(false)"
|
(startFresh)="showWelcome.set(false)"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
@import '../../../styles';
|
@import '../../../library/main';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -11,14 +12,24 @@
|
||||||
|
|
||||||
.select-add-container {
|
.select-add-container {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
margin-left: auto;
|
margin: 0 auto;
|
||||||
margin-right: auto;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
width: 80vw;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-container {
|
.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 {
|
button {
|
||||||
|
|
@ -27,5 +38,65 @@
|
||||||
&.transparent {
|
&.transparent {
|
||||||
opacity: 0;
|
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,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
OnDestroy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { StoreService } from '../../services/store.service';
|
import { StoreService } from '../../services/store.service';
|
||||||
import { PageComponent } from '../page/page.component';
|
import { PageComponent } from '../page/page.component';
|
||||||
|
|
@ -22,7 +23,7 @@ import { Page } from '../../models';
|
||||||
templateUrl: './pages.component.html',
|
templateUrl: './pages.component.html',
|
||||||
styleUrl: './pages.component.scss',
|
styleUrl: './pages.component.scss',
|
||||||
})
|
})
|
||||||
export class PagesComponent {
|
export class PagesComponent implements OnDestroy {
|
||||||
protected readonly store = inject(StoreService);
|
protected readonly store = inject(StoreService);
|
||||||
|
|
||||||
/** ID of currently selected page within store.pages(). */
|
/** ID of currently selected page within store.pages(). */
|
||||||
|
|
@ -31,22 +32,43 @@ export class PagesComponent {
|
||||||
readonly showSettings = signal(false);
|
readonly showSettings = signal(false);
|
||||||
readonly dragHappening = signal(false);
|
readonly dragHappening = signal(false);
|
||||||
readonly showWelcome = 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() {
|
constructor() {
|
||||||
effect(() => {
|
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);
|
this.showWelcome.set(true);
|
||||||
} else if (this.store.pages().length > 0) {
|
} else if (pages.length > 0) {
|
||||||
this.showWelcome.set(false);
|
this.showWelcome.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadExample(): void {
|
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);
|
this.showWelcome.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.exampleAnimationTimer !== null) {
|
||||||
|
clearTimeout(this.exampleAnimationTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
||||||
|
|
||||||
readonly selectedPage = computed<Page | null>(() => {
|
readonly selectedPage = computed<Page | null>(() => {
|
||||||
|
|
@ -61,12 +83,17 @@ export class PagesComponent {
|
||||||
return pages[0] ?? null;
|
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(() => {
|
readonly selectedPageIndex = computed(() => {
|
||||||
|
const pages = this.store.pages();
|
||||||
const page = this.selectedPage();
|
const page = this.selectedPage();
|
||||||
if (!page) return -1;
|
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 {
|
onSelectPage(index: number): void {
|
||||||
|
|
@ -94,12 +121,23 @@ export class PagesComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemovePage(): void {
|
onRequestRemovePage(): void {
|
||||||
const page = this.selectedPage();
|
const page = this.selectedPage();
|
||||||
if (!page) return;
|
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.selectedPageId.set(null);
|
||||||
this.showSettings.set(false);
|
this.showSettings.set(false);
|
||||||
|
this.confirmDeletePageId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRemovePage(): void {
|
||||||
|
this.confirmDeletePageId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSwitchAccount(token: string): void {
|
onSwitchAccount(token: string): void {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,10 @@ const FIXED_L = 0.55;
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--medium-padding);
|
padding: var(--medium-padding);
|
||||||
@include card();
|
@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 {
|
.picker {
|
||||||
|
|
@ -76,35 +79,40 @@ const FIXED_L = 0.55;
|
||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: repeat(12, 1fr);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: var(--small-padding);
|
||||||
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
all: unset;
|
all: unset;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 4px;
|
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;
|
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&: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);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.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);
|
transform: scale(1.15);
|
||||||
outline: 2px solid $light-color;
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hue-slider {
|
.hue-slider {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 12px;
|
height: 16px;
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
|
|
@ -119,11 +127,15 @@ const FIXED_L = 0.55;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
&::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
height: 24px;
|
height: 32px;
|
||||||
width: 24px;
|
width: 32px;
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
background-color: var(--thumb-color, #{$light-color});
|
background-color: var(--thumb-color, #{$light-color});
|
||||||
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
|
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
|
||||||
|
|
@ -135,8 +147,8 @@ const FIXED_L = 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-moz-range-thumb {
|
&::-moz-range-thumb {
|
||||||
height: 24px;
|
height: 32px;
|
||||||
width: 24px;
|
width: 32px;
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
background-color: var(--thumb-color, white);
|
background-color: var(--thumb-color, white);
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
|
|
@ -153,7 +165,7 @@ const FIXED_L = 0.55;
|
||||||
.preview {
|
.preview {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: var(--border-radius);
|
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%)`;
|
return `hsl(${h}, 70%, 55%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toCss(c: HslColor): string {
|
/** Re-exported so the template can call the utility directly. */
|
||||||
return toCss(c);
|
readonly toCss = toCss;
|
||||||
}
|
|
||||||
|
|
||||||
pickHue(h: number): void {
|
pickHue(h: number): void {
|
||||||
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
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".
|
* Two-thumb range slider — legacy "double-slider".
|
||||||
* Hands an indexed range over an arbitrary values array; emits the
|
* Hands an indexed range over an arbitrary values array; emits the
|
||||||
* underlying values on each change. Labels magnetically lift as a thumb
|
* 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({
|
@Component({
|
||||||
selector: 'lt-double-slider',
|
selector: 'lt-double-slider',
|
||||||
|
|
@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
|
||||||
id="ds-1"
|
id="ds-1"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
[max]="MAX - 1"
|
[max]="maxIndex()"
|
||||||
[value]="oneValue()"
|
[value]="oneValue()"
|
||||||
(input)="oneValue.set(+$any($event.target).value)"
|
(input)="oneValue.set(+$any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
|
||||||
id="ds-2"
|
id="ds-2"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
[max]="MAX - 1"
|
[max]="maxIndex()"
|
||||||
[value]="otherValue()"
|
[value]="otherValue()"
|
||||||
(input)="otherValue.set(+$any($event.target).value)"
|
(input)="otherValue.set(+$any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -65,6 +65,11 @@ export interface DoubleSliderRange<T> {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: calc(#{$slider-size} / 2) auto 0 auto;
|
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; }
|
label { display: none; }
|
||||||
|
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
|
|
@ -145,6 +150,12 @@ export interface DoubleSliderRange<T> {
|
||||||
transition: transform $long-animation-time;
|
transition: transform $long-animation-time;
|
||||||
white-space: nowrap;
|
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 rangeChange = output<DoubleSliderRange<unknown>>();
|
||||||
|
|
||||||
readonly MAX = 100;
|
|
||||||
|
|
||||||
readonly oneValue = signal(0);
|
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;
|
private prevValuesLength = 0;
|
||||||
|
|
||||||
|
|
@ -187,42 +197,44 @@ export class DoubleSliderComponent {
|
||||||
const hi = Math.max(a, b);
|
const hi = Math.max(a, b);
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
this.rangeChange.emit({
|
this.rangeChange.emit({
|
||||||
from: vs[this.indexFromValue(lo)],
|
from: vs[this.clampIndex(lo)],
|
||||||
to: vs[this.indexFromValue(hi)],
|
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(() => {
|
effect(() => {
|
||||||
const len = this.values().length;
|
const len = this.values().length;
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
|
const max = Math.max(0, len - 1);
|
||||||
if (len > this.prevValuesLength) {
|
if (len > this.prevValuesLength) {
|
||||||
const a = this.oneValue();
|
const a = this.oneValue();
|
||||||
const b = this.otherValue();
|
const b = this.otherValue();
|
||||||
if (a > b) this.oneValue.set(this.MAX - 1);
|
if (a > b) this.oneValue.set(max);
|
||||||
else this.otherValue.set(this.MAX - 1);
|
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;
|
this.prevValuesLength = len;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private indexFromValue(value: number): number {
|
private clampIndex(value: number): number {
|
||||||
return Math.min(
|
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
|
||||||
this.values().length - 1,
|
|
||||||
Math.floor((value / this.MAX) * this.values().length),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Magnetic label position: returns a CSS `transform` that lifts the label
|
* 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 {
|
getOffset(index: number): string {
|
||||||
const labelIndex = index / Math.max(1, this.drawnLabels().length);
|
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||||
const a = this.oneValue() / this.MAX - 0.1;
|
const max = Math.max(1, this.maxIndex());
|
||||||
const b = this.otherValue() / this.MAX - 0.1;
|
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 dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
||||||
const ACTIVE_ZONE = 0.2;
|
const ACTIVE_ZONE = 0.2;
|
||||||
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import {
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
OnChanges,
|
ElementRef,
|
||||||
SimpleChanges,
|
HostListener,
|
||||||
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -19,7 +20,15 @@ import {
|
||||||
[class.always-shadow]="alwaysDropShadow()"
|
[class.always-shadow]="alwaysDropShadow()"
|
||||||
>
|
>
|
||||||
<div class="background" [class.active]="open()"></div>
|
<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>
|
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,10 +39,13 @@ import {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[value]="item"
|
[value]="item"
|
||||||
|
maxlength="200"
|
||||||
(blur)="onRename(item, $any($event.target).value)"
|
(blur)="onRename(item, $any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<p (click)="onSelectItem(item)">{{ item }}</p>
|
<button class="option" type="button" (click)="onSelectItem(item)">
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
|
|
@ -41,11 +53,23 @@ import {
|
||||||
type="text"
|
type="text"
|
||||||
#addInput
|
#addInput
|
||||||
placeholder="Add a value…"
|
placeholder="Add a value…"
|
||||||
|
maxlength="200"
|
||||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
(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()) {
|
@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" />
|
<img src="assets/pen.svg" alt="Edit" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +82,7 @@ import {
|
||||||
@import '../../../../library/main';
|
@import '../../../../library/main';
|
||||||
|
|
||||||
$inner-padding: var(--medium-padding);
|
$inner-padding: var(--medium-padding);
|
||||||
|
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -80,13 +105,22 @@ import {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 46px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: var(--small-padding);
|
||||||
|
|
||||||
p {
|
p {
|
||||||
display: inline-block;
|
display: block;
|
||||||
@include sub-title-text();
|
@include sub-title-text();
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
img.arrow {
|
img.arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
@include square(16px);
|
@include square(16px);
|
||||||
transition: transform $long-animation-time;
|
transition: transform $long-animation-time;
|
||||||
|
|
||||||
|
|
@ -98,81 +132,129 @@ import {
|
||||||
|
|
||||||
.bottom-container {
|
.bottom-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
overflow-y: hidden;
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow: visible;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
position: absolute;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
pointer-events: all;
|
pointer-events: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||||
padding: $inner-padding;
|
padding: $inner-padding;
|
||||||
padding-top: 0;
|
padding-top: var(--small-padding);
|
||||||
@include inner-spacing($inner-padding);
|
gap: var(--small-padding);
|
||||||
// Default (closed) state — also the target of the close transition.
|
// Default (closed) state — also the target of the close transition.
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: translateY(-100%);
|
transform: translateY(-8px);
|
||||||
|
opacity: 0;
|
||||||
visibility: hidden;
|
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
|
// Delay the visibility change until after the slide animation finishes
|
||||||
// so the panel stays visible while it animates closed.
|
// so the panel stays visible while it animates closed.
|
||||||
transition:
|
transition:
|
||||||
transform $long-animation-time,
|
transform $long-animation-time,
|
||||||
|
opacity $long-animation-time,
|
||||||
background-color $long-animation-time,
|
background-color $long-animation-time,
|
||||||
box-shadow $long-animation-time,
|
box-shadow $long-animation-time,
|
||||||
visibility 0s $long-animation-time;
|
visibility 0s $long-animation-time;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
pointer-events: all;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
background-color: $light-color;
|
background-color: $light-color;
|
||||||
box-shadow: $shadow;
|
box-shadow: $dropdown-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);
|
|
||||||
// On open, visibility flips immediately (no delay); transform +
|
// On open, visibility flips immediately (no delay); transform +
|
||||||
// colors + shadow animate over $long-animation-time.
|
// colors + shadow animate over $long-animation-time.
|
||||||
transition:
|
transition:
|
||||||
transform $long-animation-time,
|
transform $long-animation-time,
|
||||||
|
opacity $long-animation-time,
|
||||||
background-color $long-animation-time,
|
background-color $long-animation-time,
|
||||||
box-shadow $long-animation-time,
|
box-shadow $long-animation-time,
|
||||||
visibility 0s 0s;
|
visibility 0s 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.option {
|
||||||
@include sub-title-text();
|
@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;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'] {
|
input[type='text'] {
|
||||||
@include sub-title-text();
|
@include sub-title-text();
|
||||||
width: 100%;
|
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 {
|
.add-row {
|
||||||
height: 32px;
|
min-height: 40px;
|
||||||
@media (max-width: $mobile-width) { height: 24px; }
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
gap: var(--small-padding);
|
gap: var(--small-padding);
|
||||||
|
|
||||||
input[type='text'] {
|
input[type='text'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: solid 2px transparent;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: none;
|
||||||
|
border-bottom-color: $text-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: static;
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&.add-button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
&.pen {
|
&.pen {
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
|
|
@ -184,6 +266,10 @@ import {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
// Kill the global button's hover-grow underline pseudo-element
|
||||||
|
// for the icon-only edit control.
|
||||||
|
&:after { content: none; display: none; }
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@include square(16px);
|
@include square(16px);
|
||||||
}
|
}
|
||||||
|
|
@ -224,16 +310,18 @@ import {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@include card();
|
@include card();
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
box-sizing: border-box;
|
||||||
transition:
|
transition:
|
||||||
box-shadow $long-animation-time,
|
box-shadow $long-animation-time,
|
||||||
height $long-animation-time,
|
height $long-animation-time,
|
||||||
border-radius $long-animation-time;
|
border-radius $long-animation-time;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
box-shadow: $shadow;
|
// Same shadow recipe as the panel below so the two halves read as
|
||||||
// Show shadow on top/left/right only; clip the bottom edge so the
|
// one continuous card. Clip the bottom edge so this shadow doesn't
|
||||||
// shadow doesn't bleed over the seam where .top meets .bottom.
|
// bleed across the seam where .top meets .bottom.
|
||||||
clip-path: inset(-6px -6px 0 -6px);
|
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 {
|
&:hover {
|
||||||
@media (min-width: $mobile-width) {
|
@media (min-width: $mobile-width) {
|
||||||
.background { box-shadow: $shadow; }
|
.background:not(.active) { box-shadow: $shadow; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.shadow-border {
|
&.shadow-border {
|
||||||
.background.active {
|
.background.active {
|
||||||
box-shadow: $shadow-border;
|
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 {
|
&.shadow-border:hover {
|
||||||
.background { box-shadow: $shadow-border; }
|
.background:not(.active) { box-shadow: $shadow-border; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.always-shadow {
|
&.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
|
// When open, clip the bottom so the always-on shadow doesn't bleed
|
||||||
// over the seam; restore full shadow when closed.
|
// over the seam; restore full shadow when closed.
|
||||||
&:has(.bottom.open) .background {
|
&:has(.bottom.open) .background {
|
||||||
|
|
@ -279,7 +373,7 @@ import {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class SelectAddComponent implements OnChanges {
|
export class SelectAddComponent {
|
||||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||||
/** List of string options */
|
/** List of string options */
|
||||||
readonly items = input<string[]>([]);
|
readonly items = input<string[]>([]);
|
||||||
|
|
@ -309,6 +403,7 @@ export class SelectAddComponent implements OnChanges {
|
||||||
// ── Internal state ─────────────────────────────────────────────────────────
|
// ── Internal state ─────────────────────────────────────────────────────────
|
||||||
readonly open = signal(false);
|
readonly open = signal(false);
|
||||||
readonly editing = signal(false);
|
readonly editing = signal(false);
|
||||||
|
private readonly host = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
// Resolved values that merge old + new API
|
// Resolved values that merge old + new API
|
||||||
protected resolvedItems(): string[] {
|
protected resolvedItems(): string[] {
|
||||||
|
|
@ -320,7 +415,7 @@ export class SelectAddComponent implements OnChanges {
|
||||||
protected resolvedSelected(): string | null {
|
protected resolvedSelected(): string | null {
|
||||||
// New API: string
|
// New API: string
|
||||||
const s = this.selected();
|
const s = this.selected();
|
||||||
if (s != null) return s;
|
if (s != null && s.trim()) return s;
|
||||||
// Legacy API: index into options
|
// Legacy API: index into options
|
||||||
const idx = this.selectedIndex();
|
const idx = this.selectedIndex();
|
||||||
const opts = this.resolvedItems();
|
const opts = this.resolvedItems();
|
||||||
|
|
@ -328,8 +423,18 @@ export class SelectAddComponent implements OnChanges {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(_changes: SimpleChanges): void {
|
@HostListener('document:click', ['$event'])
|
||||||
// Nothing to do — signals handle reactivity
|
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 {
|
onSelectItem(item: string): void {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,14 @@ import {
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="toggle">
|
<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>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -20,7 +27,14 @@ import {
|
||||||
(change)="set(!checked())"
|
(change)="set(!checked())"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
|
@ -30,7 +44,12 @@ import {
|
||||||
$size: 30px;
|
$size: 30px;
|
||||||
|
|
||||||
@include center-child();
|
@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 {
|
.toggle {
|
||||||
display: contents;
|
display: contents;
|
||||||
|
|
@ -41,7 +60,7 @@ import {
|
||||||
// Fixed width (not max-width) so multiple toggles align column-wise
|
// Fixed width (not max-width) so multiple toggles align column-wise
|
||||||
// — the thumb position is identical across rows regardless of label.
|
// — the thumb position is identical across rows regardless of label.
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 4 * $size;
|
width: var(--toggle-label-width, #{4 * $size});
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 var(--small-padding);
|
padding: 0 var(--small-padding);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
@ -50,10 +69,19 @@ import {
|
||||||
&.active { font-weight: bold; }
|
&.active { font-weight: bold; }
|
||||||
&:first-of-type { text-align: right; }
|
&:first-of-type { text-align: right; }
|
||||||
&:last-of-type { text-align: left; }
|
&: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 {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type='checkbox'] {
|
||||||
-webkit-appearance: none;
|
-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 { Block, HslColor } from '../../models';
|
||||||
import { getColorOfTag } from '../../utils/color';
|
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.
|
* 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 colored tickbox marks the task done.
|
||||||
* Clicking the description opens the block-edit modal via the `edit` output.
|
* Clicking the description opens the block-edit modal via the `edit` output.
|
||||||
*/
|
*/
|
||||||
|
|
@ -17,16 +35,29 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
<div
|
<div
|
||||||
class="container"
|
class="container"
|
||||||
[class.show-hover]="pending().length > 0"
|
[class.show-hover]="pending().length > 0"
|
||||||
(click)="expanded.update(v => !v)"
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(mousedown)="$event.stopPropagation()"
|
||||||
|
(touchstart)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<p class="header">
|
@if (!initiallyOpen()) {
|
||||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
<button
|
||||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
type="button"
|
||||||
</p>
|
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
|
<div
|
||||||
class="all-task"
|
|
||||||
#all
|
#all
|
||||||
[style.height.px]="expanded() ? all.scrollHeight : 0"
|
class="all-task"
|
||||||
|
[style.max-height]="taskListMaxHeight(expanded())"
|
||||||
>
|
>
|
||||||
@for (b of pending(); track b.id) {
|
@for (b of pending(); track b.id) {
|
||||||
<div class="task-container">
|
<div class="task-container">
|
||||||
|
|
@ -34,13 +65,17 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
type="button"
|
type="button"
|
||||||
class="tickbox"
|
class="tickbox"
|
||||||
[style.background-color]="colorOf(b.tag)"
|
[style.background-color]="colorOf(b.tag)"
|
||||||
|
(pointerup)="$event.stopPropagation()"
|
||||||
|
(touchend)="$event.stopPropagation()"
|
||||||
(click)="$event.stopPropagation(); markDone.emit(b)"
|
(click)="$event.stopPropagation(); markDone.emit(b)"
|
||||||
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
||||||
></button>
|
></button>
|
||||||
<p
|
<button
|
||||||
|
type="button"
|
||||||
|
class="task-description"
|
||||||
[style.color]="colorOf(b.tag)"
|
[style.color]="colorOf(b.tag)"
|
||||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||||
>{{ b.description || b.tag }}</p>
|
>{{ b.description || b.tag }}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -73,26 +108,51 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
overflow-y: auto;
|
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 {
|
.all-task {
|
||||||
@include inner-spacing(var(--small-padding));
|
@include inner-spacing(var(--small-padding));
|
||||||
|
|
||||||
:first-child { margin-top: var(--small-padding); }
|
:first-child { margin-top: var(--small-padding); }
|
||||||
|
|
||||||
height: 0;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: height $long-animation-time;
|
transition: max-height $long-animation-time;
|
||||||
overflow-y: hidden;
|
|
||||||
|
/*
|
||||||
|
* 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 {
|
.task-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--small-padding);
|
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) {
|
@media (min-width: $mobile-width) {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
@ -102,14 +162,16 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
// done without opening the edit carousel. Hover & focus reveal a
|
// done without opening the edit carousel. Hover & focus reveal a
|
||||||
// subtle inner check mark.
|
// subtle inner check mark.
|
||||||
.tickbox {
|
.tickbox {
|
||||||
flex: 0 0 auto;
|
|
||||||
all: unset; // strip native button styles
|
all: unset; // strip native button styles
|
||||||
|
flex: 0 0 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@include square(24px);
|
@include square(24px);
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
@include square(20px);
|
@include square(24px);
|
||||||
}
|
}
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: $shadow-border;
|
box-shadow: $shadow-border;
|
||||||
|
|
@ -119,14 +181,25 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
content: '✓';
|
content: '✓';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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();
|
@include center-child();
|
||||||
color: $light-color;
|
color: $light-color;
|
||||||
font-size: 18px;
|
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
|
||||||
font-weight: bold;
|
opacity: 0; // hidden at rest — only revealed on hover/focus/active
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.5;
|
|
||||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
|
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;
|
transition: opacity $short-animation-time, transform $short-animation-time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,23 +211,29 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
}
|
}
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.95);
|
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;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@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;
|
position: relative;
|
||||||
|
&::after { content: none; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,20 +252,28 @@ export class TasksComponent {
|
||||||
/** Emitted when the description is clicked — parent opens the block-edit modal. */
|
/** Emitted when the description is clicked — parent opens the block-edit modal. */
|
||||||
readonly edit = output<Block>();
|
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() {
|
constructor() {
|
||||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
// When the page setting switches back to collapsed, discard any older manual
|
||||||
// the "Keep tasks open" page setting expands/collapses the accordion live.
|
// open state so the setting is reflected immediately.
|
||||||
// User clicks (which mutate `expanded` directly) are respected until the
|
|
||||||
// setting changes again.
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const open = this.initiallyOpen();
|
const keepOpen = this.initiallyOpen();
|
||||||
untracked(() => this.expanded.set(open));
|
if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
colorOf(tag: string): string {
|
colorOf(tag: string): string {
|
||||||
return getColorOfTag(tag, this.baseColor());
|
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,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
untracked,
|
untracked,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Tower, Block } from '../../models';
|
import { Tower, Block } from '../../models';
|
||||||
import { BlockComponent } from '../block/block.component';
|
import { BlockComponent } from '../block/block.component';
|
||||||
|
|
@ -17,67 +21,191 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
||||||
import { toCss } from '../../utils/color';
|
import { toCss } from '../../utils/color';
|
||||||
|
|
||||||
/** Tracks which entry path the block-edit modal was opened from. */
|
/** 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. */
|
/** A done block augmented with per-render animation state. */
|
||||||
interface StyledBlock extends Block {
|
export interface StyledBlock extends Block {
|
||||||
_anim: '' | 'descend' | 'ascend';
|
_anim: '' | 'descend' | 'ascend';
|
||||||
_transform: string;
|
_transform: string;
|
||||||
_opacity: 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({
|
@Component({
|
||||||
selector: 'lt-tower',
|
selector: 'lt-tower',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="tower">
|
<div #towerRoot class="tower">
|
||||||
<div class="container" [class.trash-highlight]="trashHighlight()">
|
<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
|
<lt-tasks
|
||||||
[pending]="pending()"
|
[pending]="pending()"
|
||||||
[baseColor]="tower().base_color"
|
[baseColor]="tower().base_color"
|
||||||
[initiallyOpen]="keepTasksOpen()"
|
[initiallyOpen]="keepTasksOpen()"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
(markDone)="onMarkTaskDone($event)"
|
(markDone)="onMarkTaskDone($event)"
|
||||||
(edit)="onEditBlock($event)"
|
(edit)="onEditBlock($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<div
|
||||||
src="assets/plus-sign.svg"
|
#stackZone
|
||||||
class="add-block"
|
class="stack-zone"
|
||||||
alt="Add block"
|
[style.--block-stack-height]="blockStackHeight()"
|
||||||
(click)="$event.stopPropagation(); openAddBlock()"
|
>
|
||||||
/>
|
<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-container">
|
||||||
<div class="block-container">
|
<div class="block-container">
|
||||||
@for (b of visibleBlocks(); track b.id) {
|
@for (sq of squares(); track sq.key; let i = $index) {
|
||||||
<lt-block
|
<lt-block
|
||||||
[block]="b"
|
[block]="sq.block"
|
||||||
[baseColor]="tower().base_color"
|
[baseColor]="tower().base_color"
|
||||||
[class]="b._anim"
|
[hovered]="hoveredBlockId() === sq.block.id"
|
||||||
[style.transform]="b._transform"
|
[attr.data-block-id]="sq.block.id"
|
||||||
[style.opacity]="b._opacity"
|
[class.descend]="sq.block._anim === 'descend'"
|
||||||
(clicked)="onEditBlock(b)"
|
[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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
@if (hiddenBlockCount() > 0) {
|
||||||
#nameInput
|
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
|
||||||
type="text"
|
}
|
||||||
[value]="tower().name"
|
|
||||||
[style.color]="towerNameCss()"
|
|
||||||
(blur)="onRename($event)"
|
|
||||||
(keydown.enter)="nameInput.blur()"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (editEntry(); as entry) {
|
@if (editEntry(); as entry) {
|
||||||
<lt-modal (close)="closeEdit()">
|
<lt-modal (close)="closeEdit()">
|
||||||
<lt-block-edit
|
<lt-block-edit
|
||||||
|
[viewTitle]="editViewTitle()"
|
||||||
[blocks]="filteredForEntry()"
|
[blocks]="filteredForEntry()"
|
||||||
[activeBlockId]="entry.activeId"
|
[activeBlockId]="entry.activeId"
|
||||||
[tags]="towerTags()"
|
[tags]="towerTags()"
|
||||||
|
|
@ -92,7 +220,12 @@ interface StyledBlock extends Block {
|
||||||
|
|
||||||
@if (showSettings()) {
|
@if (showSettings()) {
|
||||||
<lt-modal (close)="showSettings.set(false)">
|
<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>
|
</lt-modal>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|
@ -100,7 +233,9 @@ interface StyledBlock extends Block {
|
||||||
@import '../../../library/main';
|
@import '../../../library/main';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
&.cdk-drag-animating {
|
&.cdk-drag-animating {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
|
@ -135,12 +270,12 @@ interface StyledBlock extends Block {
|
||||||
transform: scale(0.75);
|
transform: scale(0.75);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
.tower-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,8 +284,10 @@ interface StyledBlock extends Block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
@include inner-spacing(var(--small-padding));
|
@include inner-spacing(var(--small-padding));
|
||||||
|
|
||||||
|
|
@ -158,7 +295,15 @@ interface StyledBlock extends Block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
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();
|
@include card();
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -166,9 +311,16 @@ interface StyledBlock extends Block {
|
||||||
|
|
||||||
@include inner-spacing(var(--medium-padding));
|
@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%;
|
width: 100%;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -184,108 +336,206 @@ interface StyledBlock extends Block {
|
||||||
}
|
}
|
||||||
|
|
||||||
lt-tasks {
|
lt-tasks {
|
||||||
flex: 0 0 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
max-height: 30vh;
|
max-height: min(30vh, 45%);
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: min(25vh, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img.add-block {
|
.stack-zone {
|
||||||
flex: 0 0 auto;
|
|
||||||
align-self: center;
|
|
||||||
margin: var(--medium-padding) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
flex: 1 1 auto;
|
||||||
height: 48px;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
img {
|
||||||
height: 32px;
|
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;
|
img.add-block {
|
||||||
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;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
z-index: 3;
|
||||||
width: 100%;
|
left: 50%;
|
||||||
transform: scaleY(-1);
|
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 */
|
.block-container-container {
|
||||||
* {
|
position: absolute;
|
||||||
transform: translateY(500%);
|
inset: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.descend {
|
.block-container {
|
||||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
display: flex;
|
||||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
|
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 {
|
/* Default resting position for all blocks before JS sets them */
|
||||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
* {
|
||||||
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
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'] {
|
.more-blocks {
|
||||||
font-size: var(--small-font-size);
|
@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;
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: $mobile-width) {
|
.tower-header {
|
||||||
width: 50%;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||||
readonly tower = input.required<Tower>();
|
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`
|
/** Optional date range filter — when set, blocks with `created_at`
|
||||||
* outside [from, to] are hidden from the falling stack. */
|
* outside [from, to] are hidden from the falling stack. */
|
||||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||||
/** When true, the tasks accordion starts expanded on load. */
|
/** When true, the tasks accordion starts expanded on load. */
|
||||||
readonly keepTasksOpen = input<boolean>(false);
|
readonly keepTasksOpen = input<boolean>(false);
|
||||||
|
/** When true, completed blocks descend on this tower's first measured render. */
|
||||||
|
readonly animateInitialStack = input<boolean>(false);
|
||||||
|
|
||||||
// ── Outputs ────────────────────────────────────────────────────────────────
|
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||||
readonly updateTower = output<TowerSettingsResult>();
|
readonly updateTower = output<TowerSettingsResult>();
|
||||||
readonly deleteTowerRequest = output<void>();
|
readonly deleteTowerRequest = output<void>();
|
||||||
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
/** 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. */
|
/** Emitted when an existing block is patched from the carousel. */
|
||||||
readonly saveBlock = output<{
|
readonly saveBlock = output<{
|
||||||
blockId: string;
|
blockId: string;
|
||||||
result: { tag: string; description: string; is_done: boolean };
|
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||||
}>();
|
}>();
|
||||||
readonly deleteBlock = output<string>();
|
readonly deleteBlock = output<string>();
|
||||||
|
|
||||||
|
|
@ -294,10 +544,19 @@ export class TowerComponent {
|
||||||
* which list of blocks to show and which one to focus initially. */
|
* which list of blocks to show and which one to focus initially. */
|
||||||
readonly editEntry = signal<EditEntry | null>(null);
|
readonly editEntry = signal<EditEntry | null>(null);
|
||||||
readonly showSettings = signal(false);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
/** 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. */
|
/** CSS color string for the tower name input. */
|
||||||
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||||
|
|
@ -307,7 +566,14 @@ export class TowerComponent {
|
||||||
const entry = this.editEntry();
|
const entry = this.editEntry();
|
||||||
if (!entry) return [];
|
if (!entry) return [];
|
||||||
const isDone = entry.filter === 'done';
|
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. */
|
/** Unique tags from existing blocks of this tower. */
|
||||||
|
|
@ -326,33 +592,130 @@ export class TowerComponent {
|
||||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
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 prevDoneIds: string[] = [];
|
||||||
private isFirstRun = true;
|
private isFirstRun = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const range = this.dateRange();
|
const range = this.dateRange();
|
||||||
// Always keep ALL done blocks in the visible list. The date filter
|
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||||
// only flips per-block `_anim` so the 1.5s ascend/descend transition
|
const animateInitialStack = this.animateInitialStack();
|
||||||
// can animate them in and out of the falling stack.
|
// 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);
|
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 ids = allDone.map((b) => b.id);
|
||||||
const prev = this.prevDoneIds;
|
const prev = this.prevDoneIds;
|
||||||
const prevSet = new Set(prev);
|
const prevSet = new Set(prev);
|
||||||
const newIds = ids.filter(id => !prevSet.has(id));
|
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||||
const grewByOne =
|
const grewByOne =
|
||||||
!this.isFirstRun &&
|
!this.isFirstRun &&
|
||||||
ids.length === prev.length + 1 &&
|
ids.length === prev.length + 1 &&
|
||||||
newIds.length === 1 &&
|
newIds.length === 1 &&
|
||||||
prev.every(id => ids.includes(id)); // no IDs disappeared
|
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||||
|
|
||||||
const inRange = (b: Block) =>
|
|
||||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
|
||||||
|
|
||||||
const styled: StyledBlock[] = [];
|
const styled: StyledBlock[] = [];
|
||||||
for (const b of allDone) {
|
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) {
|
if (grewByOne) {
|
||||||
const newId = newIds[0];
|
const newId = newIds[0];
|
||||||
const newBlock = styled.find(b => b.id === newId);
|
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||||
if (newBlock && inRange(newBlock)) {
|
if (newBlock) {
|
||||||
// Snap newly-added in-range block to start position, then on the next
|
// 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.
|
// paint flip it back to rest — that's what makes it visibly fall.
|
||||||
newBlock._anim = '';
|
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||||
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.prevDoneIds = ids;
|
this.prevDoneIds = ids;
|
||||||
this.isFirstRun = false;
|
this.isFirstRun = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// existing fall-through path (no growth, first run, or new block out of range):
|
// 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.prevDoneIds = ids;
|
||||||
this.isFirstRun = false;
|
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 ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onRename(event: Event): void {
|
onRename(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const newName = input.value.trim();
|
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 });
|
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||||
|
} else {
|
||||||
|
input.value = this.tower().name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEditBlock(block: Block): void {
|
onEditBlock(block: Block): void {
|
||||||
|
this.hoveredBlockId.set(null);
|
||||||
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
|
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. */
|
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
|
||||||
onMarkTaskDone(block: Block): void {
|
onMarkTaskDone(block: Block): void {
|
||||||
this.saveBlock.emit({
|
this.saveBlock.emit({
|
||||||
blockId: block.id,
|
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. */
|
/** Called by the template "Add block" plus-icon. */
|
||||||
openAddBlock(): void {
|
openAddBlock(): void {
|
||||||
this.editEntry.set({ filter: 'done', activeId: null });
|
this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeEdit(): void {
|
closeEdit(): void {
|
||||||
|
|
@ -442,11 +891,21 @@ export class TowerComponent {
|
||||||
|
|
||||||
onBlockSave(ev: BlockEditSave): void {
|
onBlockSave(ev: BlockEditSave): void {
|
||||||
if (ev.id === null) {
|
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 {
|
} else {
|
||||||
this.saveBlock.emit({
|
this.saveBlock.emit({
|
||||||
blockId: ev.id,
|
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 {
|
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);
|
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 { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||||
|
import { A11yModule } from '@angular/cdk/a11y';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lt-welcome',
|
selector: 'lt-welcome',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [A11yModule],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="welcome-card">
|
<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">
|
<p class="lead" id="welcome-description">
|
||||||
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
|
Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
|
||||||
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
|
home, or any project. Add towers for task groups, then check off tasks to build them
|
||||||
tower as a colored square — the more you do, the taller it grows.
|
block by block.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="muted">
|
<p class="sr-only">
|
||||||
Everything you write is saved to a small remote database, keyed to a private UUID token shown
|
Preview showing three towers with pending task bars at the top and completed task
|
||||||
under <em>Settings → Account</em>. Copy that token to recover your data on another device, or
|
blocks stacked below.
|
||||||
paste a friend's token to look at theirs.
|
|
||||||
</p>
|
</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">
|
<div class="actions">
|
||||||
<button type="button" (click)="startFresh.emit()">Start fresh</button>
|
<button type="button" (click)="startFresh.emit()">Start empty</button>
|
||||||
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
|
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|
@ -34,18 +100,49 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||||
|
|
||||||
:host { display: block; }
|
: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 {
|
.welcome-card {
|
||||||
@include card();
|
@include card();
|
||||||
width: 66vw;
|
width: min(560px, calc(100vw - (2 * var(--large-padding))));
|
||||||
max-width: 480px;
|
max-width: 560px;
|
||||||
@media (max-width: $mobile-width) { width: 300px; }
|
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;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: $shadow;
|
box-shadow: $shadow;
|
||||||
text-align: left;
|
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));
|
@include inner-spacing(var(--medium-padding));
|
||||||
|
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p,
|
||||||
|
dl,
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.exit {
|
.exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--medium-padding);
|
top: var(--medium-padding);
|
||||||
|
|
@ -54,29 +151,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
font-family: $title-font;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--larger-font-size);
|
||||||
text-align: center;
|
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 {
|
.tower-preview {
|
||||||
color: rgba($text-color, 0.7);
|
box-sizing: border-box;
|
||||||
font-size: var(--medium-font-size);
|
padding: var(--medium-padding) 0;
|
||||||
em { font-style: italic; }
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--large-padding);
|
gap: var(--large-padding);
|
||||||
margin-top: 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 {
|
button.primary {
|
||||||
color: $accent-color;
|
color: $accent-color;
|
||||||
border-bottom-color: rgba($accent-color, 0.33);
|
border-bottom-color: rgba($accent-color, 0.33);
|
||||||
&:after { background-color: $accent-color; }
|
&:after { background-color: $accent-color; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
gap: var(--small-padding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export interface Block {
|
||||||
tag: string;
|
tag: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_done: boolean;
|
is_done: boolean;
|
||||||
|
/** How many squares this block draws in the tower (>= 1). */
|
||||||
|
difficulty: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +40,7 @@ export type SaveStatus =
|
||||||
| 'saving'
|
| 'saving'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'retrying'
|
| 'retrying'
|
||||||
| 'error' // generic / network — will keep trying
|
| 'error' // generic / network — retries exhausted until the next mutation
|
||||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
| 'too-large' // 413 — payload exceeds the server cap, will not retry
|
||||||
| 'rate-limited' // 429 — will retry after Retry-After
|
| '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> {
|
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||||
return firstValueFrom(
|
await firstValueFrom(
|
||||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
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
|
describe('ApiService', () => {
|
||||||
vi.mock('../../environments/environment', () => ({
|
let service: ApiService;
|
||||||
environment: { apiBase: 'http://test-api', production: false },
|
let http: HttpTestingController;
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ApiService URL patterns', () => {
|
beforeEach(() => {
|
||||||
const baseUrl = 'http://test-api';
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), ApiService],
|
||||||
it('constructs correct health URL', () => {
|
});
|
||||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
service = TestBed.inject(ApiService);
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('constructs correct register URL', () => {
|
afterEach(() => {
|
||||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
http.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('constructs correct data URL', () => {
|
it('gets data with a bearer token', async () => {
|
||||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
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', () => {
|
it('puts data with a bearer token', async () => {
|
||||||
const token = 'abc-def-123';
|
const tree: TreeDto = { pages: [] };
|
||||||
const header = `Bearer ${token}`;
|
const promise = service.putData('token-1', tree);
|
||||||
expect(header).toBe('Bearer abc-def-123');
|
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.
|
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||||
*
|
*
|
||||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
* 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
|
* settings) is on screen — otherwise the user can drag towers from behind
|
||||||
* the open card.
|
* 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 { ApiService } from './api.service';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||||
|
|
||||||
const TOKEN_KEY = 'life-towers.token.v4';
|
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 DEBOUNCE_MS = 750;
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
|
|
||||||
|
|
@ -28,7 +30,7 @@ function uuidV4(): string {
|
||||||
|
|
||||||
function isUuidV4(value: string): boolean {
|
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(
|
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 */
|
/* 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 {
|
interface PendingPut {
|
||||||
token: string;
|
token: string;
|
||||||
tree: TreeDto;
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StoreService implements OnDestroy {
|
export class StoreService implements OnDestroy {
|
||||||
private readonly api = inject(ApiService);
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly analytics = inject(AnalyticsService);
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
private readonly _pages = signal<Page[]>([]);
|
private readonly _pages = signal<Page[]>([]);
|
||||||
|
|
@ -70,26 +104,22 @@ export class StoreService implements OnDestroy {
|
||||||
readonly loading = this._loading.asReadonly();
|
readonly loading = this._loading.asReadonly();
|
||||||
readonly token = this._token.asReadonly();
|
readonly token = this._token.asReadonly();
|
||||||
|
|
||||||
readonly pageCount = computed(() => this._pages().length);
|
|
||||||
|
|
||||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private retryResolver: (() => void) | null = null;
|
||||||
private flushInFlight = false;
|
private flushInFlight = false;
|
||||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||||
private dirtyDuringFlush = false;
|
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 ─────────────────────────────────────────────────────────
|
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||||
private readonly storageListener = (e: StorageEvent) => {
|
private readonly storageListener = (e: StorageEvent) => {
|
||||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||||
// Another tab switched accounts. Re-init with the new token instead of
|
this.switchToken(e.newValue);
|
||||||
// continuing to sync the old account's state to the new one.
|
} else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) {
|
||||||
this._token.set(e.newValue);
|
|
||||||
this._pages.set([]);
|
|
||||||
this._loading.set(true);
|
|
||||||
this.cancelPendingWrites();
|
|
||||||
void this.init();
|
|
||||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
|
||||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
// 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).
|
// (to avoid clobbering our own state with the other tab's older view).
|
||||||
try {
|
try {
|
||||||
|
|
@ -103,17 +133,21 @@ export class StoreService implements OnDestroy {
|
||||||
|
|
||||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private initGeneration = 0;
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (this.initPromise) return this.initPromise;
|
if (this.initPromise) return this.initPromise;
|
||||||
this.initPromise = this.doInit().finally(() => {
|
const generation = ++this.initGeneration;
|
||||||
this.initPromise = null;
|
this.initPromise = this.doInit(generation).finally(() => {
|
||||||
|
if (this.initGeneration === generation) {
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doInit(): Promise<void> {
|
private async doInit(generation: number): Promise<void> {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// (idempotent — adding the same listener twice is a no-op)
|
// (idempotent — adding the same listener twice is a no-op)
|
||||||
window.addEventListener('storage', this.storageListener);
|
window.addEventListener('storage', this.storageListener);
|
||||||
|
|
@ -123,6 +157,9 @@ export class StoreService implements OnDestroy {
|
||||||
if (stored && !isUuidV4(stored)) {
|
if (stored && !isUuidV4(stored)) {
|
||||||
// Garbage in localStorage from a buggy past version — refuse it.
|
// Garbage in localStorage from a buggy past version — refuse it.
|
||||||
stored = null;
|
stored = null;
|
||||||
|
} else if (stored) {
|
||||||
|
stored = stored.toLowerCase();
|
||||||
|
safeSet(TOKEN_KEY, stored);
|
||||||
}
|
}
|
||||||
const token = stored ?? uuidV4();
|
const token = stored ?? uuidV4();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
|
|
@ -140,7 +177,7 @@ export class StoreService implements OnDestroy {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tree = await this.api.getData(token);
|
const tree = await this.api.getData(token);
|
||||||
this.adoptServerTree(tree);
|
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { status?: number })?.status;
|
const status = (err as { status?: number })?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
|
|
@ -148,57 +185,73 @@ export class StoreService implements OnDestroy {
|
||||||
try {
|
try {
|
||||||
await this.api.register(token);
|
await this.api.register(token);
|
||||||
const tree = await this.api.getData(token);
|
const tree = await this.api.getData(token);
|
||||||
this.adoptServerTree(tree);
|
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||||
} catch {
|
} catch {
|
||||||
this.loadFromCache();
|
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.loadFromCache();
|
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||||
}
|
}
|
||||||
} finally {
|
} 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
|
* 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
|
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||||
* "server forgot me" recovery would silently wipe offline edits.
|
* "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) {
|
if (tree.pages.length === 0) {
|
||||||
const cached = safeGet(CACHE_KEY);
|
const cachedTree = this.readCachedTree(token);
|
||||||
if (cached) {
|
if (cachedTree?.pages && cachedTree.pages.length > 0) {
|
||||||
try {
|
this._pages.set(cachedTree.pages);
|
||||||
const cachedTree: TreeDto = JSON.parse(cached);
|
this.scheduleSave();
|
||||||
if (cachedTree.pages && cachedTree.pages.length > 0) {
|
return;
|
||||||
this._pages.set(cachedTree.pages);
|
|
||||||
this.scheduleSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to server-empty */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._pages.set(tree.pages);
|
this._pages.set(tree.pages);
|
||||||
this.updateCache(tree);
|
this.updateCache(token, tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadFromCache(): void {
|
private loadFromCache(token: string): void {
|
||||||
const raw = safeGet(CACHE_KEY);
|
const tree = this.readCachedTree(token);
|
||||||
if (raw) {
|
if (tree) this._pages.set(tree.pages);
|
||||||
try {
|
}
|
||||||
const tree: TreeDto = JSON.parse(raw);
|
|
||||||
this._pages.set(tree.pages);
|
private readCachedTree(token: string): TreeDto | null {
|
||||||
} catch {
|
const raw = safeGet(cacheKeyForToken(token));
|
||||||
/* ignore */
|
if (!raw) return null;
|
||||||
}
|
try {
|
||||||
|
return JSON.parse(raw) as TreeDto;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCache(tree: TreeDto): void {
|
private updateCache(token: string, tree: TreeDto, pending = false): void {
|
||||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
safeSet(cacheKeyForToken(token), JSON.stringify(tree));
|
||||||
|
if (pending) {
|
||||||
|
safeSet(pendingCacheKeyForToken(token), '1');
|
||||||
|
} else {
|
||||||
|
safeRemove(pendingCacheKeyForToken(token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelPendingWrites(): void {
|
private cancelPendingWrites(): void {
|
||||||
|
|
@ -210,6 +263,11 @@ export class StoreService implements OnDestroy {
|
||||||
clearTimeout(this.retryTimer);
|
clearTimeout(this.retryTimer);
|
||||||
this.retryTimer = null;
|
this.retryTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.retryResolver !== null) {
|
||||||
|
const resolve = this.retryResolver;
|
||||||
|
this.retryResolver = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
this.dirtyDuringFlush = false;
|
this.dirtyDuringFlush = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +284,8 @@ export class StoreService implements OnDestroy {
|
||||||
towers: [],
|
towers: [],
|
||||||
};
|
};
|
||||||
this._pages.update((pages) => [...pages, page]);
|
this._pages.update((pages) => [...pages, page]);
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackPageCreated();
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,13 +302,13 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderPages(fromIndex: number, toIndex: number): void {
|
reorderPages(fromIndex: number, toIndex: number): void {
|
||||||
|
let changed = false;
|
||||||
this._pages.update((pages) => {
|
this._pages.update((pages) => {
|
||||||
const arr = [...pages];
|
const reordered = reorder(pages, fromIndex, toIndex);
|
||||||
const [item] = arr.splice(fromIndex, 1);
|
changed = reordered !== null;
|
||||||
arr.splice(toIndex, 0, item);
|
return reordered ?? pages;
|
||||||
return arr;
|
|
||||||
});
|
});
|
||||||
this.scheduleSave();
|
if (changed) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||||
|
|
@ -256,6 +316,8 @@ export class StoreService implements OnDestroy {
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||||
);
|
);
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackTowerCreated();
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,16 +342,17 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||||
|
let changed = false;
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) => {
|
pages.map((p) => {
|
||||||
if (p.id !== pageId) return p;
|
if (p.id !== pageId) return p;
|
||||||
const towers = [...p.towers];
|
const towers = reorder(p.towers, fromIndex, toIndex);
|
||||||
const [item] = towers.splice(fromIndex, 1);
|
if (towers === null) return p;
|
||||||
towers.splice(toIndex, 0, item);
|
changed = true;
|
||||||
return { ...p, towers };
|
return { ...p, towers };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.scheduleSave();
|
if (changed) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
|
|
@ -298,26 +361,30 @@ export class StoreService implements OnDestroy {
|
||||||
tag: string,
|
tag: string,
|
||||||
description: string,
|
description: string,
|
||||||
is_done = false,
|
is_done = false,
|
||||||
|
difficulty = 1,
|
||||||
): void {
|
): void {
|
||||||
const block: Block = {
|
const block: Block = {
|
||||||
id: uuidV4(),
|
id: uuidV4(),
|
||||||
tag,
|
tag,
|
||||||
description,
|
description,
|
||||||
is_done,
|
is_done,
|
||||||
|
difficulty,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
};
|
};
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) =>
|
pages.map((p) =>
|
||||||
p.id === pageId
|
p.id === pageId
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
towers: p.towers.map((t) =>
|
towers: p.towers.map((t) =>
|
||||||
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
|
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackBlockCreated({ isDone: is_done });
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,23 +394,29 @@ export class StoreService implements OnDestroy {
|
||||||
blockId: string,
|
blockId: string,
|
||||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||||
): void {
|
): void {
|
||||||
|
let becameDone = false;
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) =>
|
pages.map((p) =>
|
||||||
p.id === pageId
|
p.id === pageId
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
towers: p.towers.map((t) =>
|
towers: p.towers.map((t) =>
|
||||||
t.id === towerId
|
t.id === towerId
|
||||||
? {
|
? (() => {
|
||||||
...t,
|
const result = this.patchBlockList(t.blocks, blockId, patch);
|
||||||
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
|
becameDone = result.becameDone;
|
||||||
}
|
return { ...t, blocks: result.blocks };
|
||||||
: t,
|
})()
|
||||||
),
|
: t,
|
||||||
}
|
),
|
||||||
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (becameDone) {
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackBlockCompleted();
|
||||||
|
}
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,40 +425,49 @@ export class StoreService implements OnDestroy {
|
||||||
pages.map((p) =>
|
pages.map((p) =>
|
||||||
p.id === pageId
|
p.id === pageId
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
towers: p.towers.map((t) =>
|
towers: p.towers.map((t) =>
|
||||||
t.id === towerId
|
t.id === towerId
|
||||||
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
|
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
|
||||||
: t,
|
: t,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
private patchBlockList(
|
||||||
this._pages.update((pages) =>
|
blocks: Block[],
|
||||||
pages.map((p) =>
|
blockId: string,
|
||||||
p.id === pageId
|
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||||
? {
|
): { blocks: Block[]; becameDone: boolean } {
|
||||||
...p,
|
const nextBlocks: Block[] = [];
|
||||||
towers: p.towers.map((t) =>
|
let completedBlock: Block | null = null;
|
||||||
t.id === towerId
|
let becameDone = false;
|
||||||
? {
|
|
||||||
...t,
|
for (const block of blocks) {
|
||||||
blocks: t.blocks.map((b) =>
|
if (block.id !== blockId) {
|
||||||
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
|
nextBlocks.push(block);
|
||||||
),
|
continue;
|
||||||
}
|
}
|
||||||
: t,
|
|
||||||
),
|
const updated: Block = { ...block, ...patch };
|
||||||
}
|
becameDone = !block.is_done && updated.is_done;
|
||||||
: p,
|
if (becameDone) {
|
||||||
),
|
// Display newly completed todos as the newest square, without changing
|
||||||
);
|
// created_at because the date slider still filters on creation time.
|
||||||
this.scheduleSave();
|
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).
|
* if the timing flipped).
|
||||||
*/
|
*/
|
||||||
switchToken(newToken: string): void {
|
switchToken(newToken: string): void {
|
||||||
if (!isUuidV4(newToken)) return;
|
const token = newToken.toLowerCase();
|
||||||
|
if (!isUuidV4(token)) return;
|
||||||
this.cancelPendingWrites();
|
this.cancelPendingWrites();
|
||||||
safeSet(TOKEN_KEY, newToken);
|
this.initGeneration++;
|
||||||
this._token.set(newToken);
|
this.initPromise = null;
|
||||||
|
safeSet(TOKEN_KEY, token);
|
||||||
|
this._token.set(token);
|
||||||
this._pages.set([]);
|
this._pages.set([]);
|
||||||
this._loading.set(true);
|
this._loading.set(true);
|
||||||
this._saveStatus.set('idle');
|
this._saveStatus.set('idle');
|
||||||
|
this.localMutationRevision = 0;
|
||||||
void this.init();
|
void this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private scheduleSave(): void {
|
private scheduleSave(): void {
|
||||||
|
this.localMutationRevision += 1;
|
||||||
|
const token = this._token();
|
||||||
|
if (token) this.updateCache(token, { pages: this._pages() }, true);
|
||||||
|
|
||||||
if (this.flushInFlight) {
|
if (this.flushInFlight) {
|
||||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||||
this.dirtyDuringFlush = true;
|
this.dirtyDuringFlush = true;
|
||||||
|
|
@ -435,6 +525,7 @@ export class StoreService implements OnDestroy {
|
||||||
|
|
||||||
this.flushInFlight = true;
|
this.flushInFlight = true;
|
||||||
this.dirtyDuringFlush = false;
|
this.dirtyDuringFlush = false;
|
||||||
|
const revision = this.localMutationRevision;
|
||||||
|
|
||||||
// Cancel any pending retry — runFlush() supersedes it.
|
// Cancel any pending retry — runFlush() supersedes it.
|
||||||
if (this.retryTimer !== null) {
|
if (this.retryTimer !== null) {
|
||||||
|
|
@ -443,7 +534,7 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0);
|
||||||
} finally {
|
} finally {
|
||||||
this.flushInFlight = false;
|
this.flushInFlight = false;
|
||||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||||
|
|
@ -459,7 +550,13 @@ export class StoreService implements OnDestroy {
|
||||||
try {
|
try {
|
||||||
await this.api.putData(put.token, put.tree);
|
await this.api.putData(put.token, put.tree);
|
||||||
this._saveStatus.set('saved');
|
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;
|
return;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { status?: number })?.status;
|
const status = (err as { status?: number })?.status;
|
||||||
|
|
@ -503,8 +600,10 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
this.retryResolver = resolve;
|
||||||
this.retryTimer = setTimeout(() => {
|
this.retryTimer = setTimeout(() => {
|
||||||
this.retryTimer = null;
|
this.retryTimer = null;
|
||||||
|
this.retryResolver = null;
|
||||||
resolve();
|
resolve();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
});
|
});
|
||||||
|
|
@ -512,13 +611,16 @@ export class StoreService implements OnDestroy {
|
||||||
// The token may have changed during the wait — re-check before retrying.
|
// The token may have changed during the wait — re-check before retrying.
|
||||||
if (this._token() !== put.token) return;
|
if (this._token() !== put.token) return;
|
||||||
// Re-snapshot the latest pages so the retry pushes current state.
|
// 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 ──────────────────────────────────────────────────────────
|
// ── Example data ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
loadExample(): void {
|
loadExample(): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const page: Page = {
|
const page: Page = {
|
||||||
|
|
@ -529,38 +631,83 @@ export class StoreService implements OnDestroy {
|
||||||
default_date_from: null,
|
default_date_from: null,
|
||||||
default_date_to: null,
|
default_date_to: null,
|
||||||
towers: [
|
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, [
|
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: 'novel',
|
||||||
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
|
desc: 'Finish The Brothers Karamazov',
|
||||||
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
|
done: false,
|
||||||
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
|
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, [
|
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',
|
||||||
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
|
desc: 'Modernise the towers app',
|
||||||
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
|
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, [
|
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
|
||||||
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
|
{ tag: 'run', desc: '10k Sunday', done: false, difficulty: 2, ageHrs: 0 },
|
||||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
|
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, difficulty: 4, ageHrs: 4 },
|
||||||
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
|
...this.makeExampleDoneBlocks(
|
||||||
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
|
[
|
||||||
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
|
{ 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._pages.update((pages) => [...pages, page]);
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackExampleLoaded();
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
|
return page.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeExampleTower(
|
private makeExampleTower(
|
||||||
name: string,
|
name: string,
|
||||||
base_color: HslColor,
|
base_color: HslColor,
|
||||||
nowSec: number,
|
nowSec: number,
|
||||||
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
|
blocks: ExampleBlockSeed[],
|
||||||
): Tower {
|
): Tower {
|
||||||
return {
|
return {
|
||||||
id: uuidV4(),
|
id: uuidV4(),
|
||||||
|
|
@ -571,11 +718,30 @@ export class StoreService implements OnDestroy {
|
||||||
tag: b.tag,
|
tag: b.tag,
|
||||||
description: b.desc,
|
description: b.desc,
|
||||||
is_done: b.done,
|
is_done: b.done,
|
||||||
|
difficulty: b.difficulty ?? 1,
|
||||||
created_at: nowSec - Math.floor(b.ageHrs * 3600),
|
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 {
|
ngOnDestroy(): void {
|
||||||
this.cancelPendingWrites();
|
this.cancelPendingWrites();
|
||||||
if (typeof window !== 'undefined') {
|
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 { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
import type { TreeDto } from '../models';
|
import type { TreeDto } from '../models';
|
||||||
|
|
||||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||||
|
|
@ -63,7 +64,10 @@ function makeMockApi(): MockApi {
|
||||||
|
|
||||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||||
const TOKEN_KEY = 'life-towers.token.v4';
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function configure(api: MockApi): StoreService {
|
function configure(api: MockApi): StoreService {
|
||||||
|
|
@ -72,6 +76,18 @@ function configure(api: MockApi): StoreService {
|
||||||
providers: [
|
providers: [
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
{ provide: ApiService, useValue: api },
|
{ 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,
|
StoreService,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -137,6 +153,18 @@ describe('StoreService', () => {
|
||||||
expect(store.token()).toBe(FIXED_UUID);
|
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 () => {
|
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||||
const api = makeMockApi();
|
const api = makeMockApi();
|
||||||
|
|
@ -218,6 +246,98 @@ describe('StoreService', () => {
|
||||||
expect(api.getData).toHaveBeenCalledTimes(1);
|
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 ─────────────────────────────────────────────────────────
|
// ── Debounced save ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
||||||
|
|
@ -233,6 +353,7 @@ describe('StoreService', () => {
|
||||||
expect(api.putData).not.toHaveBeenCalled();
|
expect(api.putData).not.toHaveBeenCalled();
|
||||||
await vi.advanceTimersByTimeAsync(750);
|
await vi.advanceTimersByTimeAsync(750);
|
||||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||||
const [, tree] = api.putData.mock.calls[0];
|
const [, tree] = api.putData.mock.calls[0];
|
||||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
@ -263,6 +384,97 @@ describe('StoreService', () => {
|
||||||
expect(lastTree.pages).toHaveLength(2);
|
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 ────────────────────────────────────────────────────────
|
// ── Error handling ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
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.
|
// Mutate, then switch BEFORE the debounce fires.
|
||||||
store.addPage('old-account');
|
store.addPage('old-account');
|
||||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
|
||||||
api.getData.mockResolvedValue({ pages: [] });
|
api.getData.mockResolvedValue({ pages: [] });
|
||||||
store.switchToken(newToken);
|
store.switchToken(OTHER_TOKEN);
|
||||||
|
|
||||||
// Run all timers — the OLD debounce must have been cancelled,
|
// Run all timers — the OLD debounce must have been cancelled,
|
||||||
// so no PUT should have happened.
|
// so no PUT should have happened.
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(api.putData).not.toHaveBeenCalled();
|
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', () => {
|
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||||
|
|
@ -370,6 +640,19 @@ describe('StoreService', () => {
|
||||||
expect(store.token()).toBe(''); // never initialized
|
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 ────────────────────────────────────────────────────────
|
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
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;
|
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||||
}
|
}
|
||||||
// Map the signed int32 to [0, 1) — same formula as legacy
|
// 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" />
|
<meta charset="utf-8" />
|
||||||
<title>Life Towers</title>
|
<title>Life Towers</title>
|
||||||
<base href="/" />
|
<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="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="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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ input[type='text'] {
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: 0 1px $text-color;
|
box-shadow: 0 1px $text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 2px $text-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|
@ -56,6 +60,19 @@ button {
|
||||||
border-bottom: solid $height #5d576b55;
|
border-bottom: solid $height #5d576b55;
|
||||||
position: relative;
|
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 {
|
&:disabled {
|
||||||
color: #5d576b55;
|
color: #5d576b55;
|
||||||
border-bottom: solid $height #5d576b33;
|
border-bottom: solid $height #5d576b33;
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,31 @@
|
||||||
|
|
||||||
@mixin exit {
|
@mixin exit {
|
||||||
@include square(16px);
|
@include square(16px);
|
||||||
background: url('/assets/x-sign.svg') no-repeat center center;
|
-webkit-appearance: none;
|
||||||
background-size: 50% 50%;
|
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;
|
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;
|
padding: 8px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
|
||||||
@include jump();
|
@include jump();
|
||||||
|
|
||||||
|
&:before,
|
||||||
|
&:after {
|
||||||
|
content: none !important;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,22 @@
|
||||||
margin-bottom: $spacing;
|
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');
|
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';
|
@import 'library/main';
|
||||||
|
|
||||||
$line-height: 2px;
|
$line-height: 2px;
|
||||||
|
|
@ -56,7 +24,7 @@ $line-height: 2px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus:not(:focus-visible) {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.vitest.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.vitest.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'],
|
include: ['src/**/*.vitest.ts'],
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|
|
||||||