not bad
This commit is contained in:
parent
e2a60e71a3
commit
003f38ea60
36 changed files with 1543 additions and 1287 deletions
|
|
@ -8,77 +8,150 @@ on:
|
|||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Sync dependencies
|
||||
working-directory: backend
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
- name: Run pytest
|
||||
run: uv run pytest -v
|
||||
|
||||
frontend:
|
||||
frontend-lint:
|
||||
name: Frontend lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint (if configured)
|
||||
working-directory: frontend
|
||||
run: |
|
||||
if [ -f eslint.config.js ] || [ -f .eslintrc.json ] || [ -f .eslintrc.js ]; then
|
||||
npm run lint
|
||||
else
|
||||
echo "No ESLint config found, skipping lint"
|
||||
fi
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
docker:
|
||||
frontend-test:
|
||||
name: Frontend unit tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t life-towers:${{ github.sha }} .
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Push to registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
env:
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest
|
||||
run: npm test
|
||||
|
||||
frontend-build:
|
||||
name: Frontend build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
e2e:
|
||||
name: Playwright e2e
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend-build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Start stack
|
||||
run: docker compose -p life-towers -f docker-compose.dev.yml up --build -d
|
||||
|
||||
- name: Wait for /api/v1/health
|
||||
run: |
|
||||
if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then
|
||||
echo "Registry secrets not configured, skipping push"
|
||||
set -e
|
||||
cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers)
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
|
||||
if [ "$status" = healthy ]; then
|
||||
echo "stack healthy after ${i} attempts"
|
||||
exit 0
|
||||
fi
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin
|
||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
||||
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:latest"
|
||||
docker push "$REGISTRY_URL/life-towers:${{ github.sha }}"
|
||||
docker push "$REGISTRY_URL/life-towers:latest"
|
||||
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
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
|
||||
|
|
@ -224,9 +224,11 @@ When `values.length` grows (new block added), the slider snaps the **higher** of
|
|||
|
||||
### Tickbox (tasks/tasks.component)
|
||||
|
||||
- Always-visible `✓` glyph at `opacity: 0.45` (rest), `0.85` (hover), `1` (active)
|
||||
- `transform: translateY(2px)` is critical — Unicode `✓` has uneven font-metrics, geometric centering looks half a square too high. The 2px nudge moves it to optical center
|
||||
- `:active` state must re-state the translateY or the glyph jumps when pressed
|
||||
- `✓` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
|||
| `LIFE_TOWERS_PORT` | `8000` | Host port mapped to the container. |
|
||||
| `LIFE_TOWERS_PULL_POLICY` | `missing` | `pull_policy` passed to compose. Set `always` to force-pull on `up`. |
|
||||
| `LIFE_TOWERS_ALLOWED_ORIGIN` | _(empty)_ | If set, restricts CORS to this origin. Leave empty for same-origin mode (the typical setup behind nginx). |
|
||||
| `LIFE_TOWERS_PUBLIC_URL` | _(derived from request)_ | Absolute public URL used for canonical and Open Graph image tags. Set this when serving behind a path prefix or proxy that rewrites the visible URL. |
|
||||
| `LIFE_TOWERS_FORWARDED_ALLOW_IPS` | `*` | (Optional, advanced.) Override uvicorn's `--forwarded-allow-ips` if you want to narrow the set of trusted proxies. |
|
||||
|
||||
## Data persistence
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
|
@ -151,7 +153,44 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
|
||||
)
|
||||
|
||||
def _serve_file(file_path: Path) -> FileResponse:
|
||||
def _absolute_meta_urls(request: Request) -> tuple[str, str]:
|
||||
configured_public_url = os.environ.get("LIFE_TOWERS_PUBLIC_URL", "").strip()
|
||||
if configured_public_url:
|
||||
public_root = configured_public_url.rstrip("/") + "/"
|
||||
return public_root, f"{public_root}og-image.png"
|
||||
|
||||
parts = urlsplit(str(request.url))
|
||||
canonical_url = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", "", ""))
|
||||
|
||||
root_path = str(request.scope.get("root_path") or "").strip("/")
|
||||
og_image_path = f"/{root_path}/og-image.png" if root_path else "/og-image.png"
|
||||
og_image_url = urlunsplit((parts.scheme, parts.netloc, og_image_path, "", ""))
|
||||
return canonical_url, og_image_url
|
||||
|
||||
def _serve_index(file_path: Path, request: Request) -> HTMLResponse:
|
||||
canonical_url, og_image_url = _absolute_meta_urls(request)
|
||||
html = file_path.read_text(encoding="utf-8")
|
||||
html = html.replace(
|
||||
'href="/" data-dynamic-url="canonical"',
|
||||
f'href="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/" data-dynamic-url="canonical"',
|
||||
f'content="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/og-image.png" data-dynamic-url="og-image"',
|
||||
f'content="{escape(og_image_url, quote=True)}" data-dynamic-url="og-image"',
|
||||
)
|
||||
|
||||
resp = HTMLResponse(html)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
def _serve_file(file_path: Path, request: Request) -> Response:
|
||||
if file_path.name == "index.html":
|
||||
return _serve_index(file_path, request)
|
||||
|
||||
resp = FileResponse(str(file_path))
|
||||
if HASHED_PATTERN.search(file_path.name):
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
|
|
@ -160,7 +199,7 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
return resp
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str) -> Response:
|
||||
async def spa_fallback(request: Request, full_path: str) -> Response:
|
||||
# API routes are handled by the API router (registered before this);
|
||||
# if execution reaches here for an /api/* path, it really is unknown.
|
||||
if full_path.startswith("api/"):
|
||||
|
|
@ -174,12 +213,12 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if candidate.is_file():
|
||||
return _serve_file(candidate)
|
||||
return _serve_file(candidate, request)
|
||||
|
||||
# SPA fallback to index.html.
|
||||
index = static_dir / "index.html"
|
||||
if index.is_file():
|
||||
return _serve_file(index)
|
||||
return _serve_file(index, request)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,73 @@ async def test_health(client: AsyncClient) -> None:
|
|||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "index.html").write_text(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
</head>
|
||||
</html>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(static_dir / "og-image.png").write_bytes(b"fake png")
|
||||
monkeypatch.setenv("LIFE_TOWERS_STATIC_DIR", str(static_dir))
|
||||
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://towers.example",
|
||||
) as c:
|
||||
resp = await c.get("/tasks?utm_source=test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:url" content="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta name="twitter:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
monkeypatch.setenv("LIFE_TOWERS_PUBLIC_URL", "https://public.example/towers")
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://internal.example",
|
||||
) as c:
|
||||
resp = await c.get("/")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://public.example/towers/" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://public.example/towers/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
795
docs/DESIGN.md
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
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.
|
||||
|
|
@ -53,11 +53,8 @@ test.describe('Life Towers visuals', () => {
|
|||
await createCard
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Finish The Brothers Karamazov');
|
||||
// Toggle to "Task hasn't been finished yet" so this becomes a pending task.
|
||||
await createCard
|
||||
.locator('lt-toggle span')
|
||||
.filter({ hasText: "This task hasn't been finished yet" })
|
||||
.click();
|
||||
// Uncheck "Already done" so this becomes a pending task.
|
||||
await createCard.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
|
|
@ -67,6 +64,15 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
||||
|
||||
// Hover the tickbox: must NOT pop a scrollbar in the accordion, must NOT
|
||||
// paint the global button-underline bar across the top, and the ✓ must stay
|
||||
// centred (regression guard — see tasks.component .tickbox::after).
|
||||
await page.locator('lt-tasks .tickbox').first().hover();
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('lt-tasks .container').screenshot({
|
||||
path: 'visuals/04c-tasks-tickbox-hover.png',
|
||||
});
|
||||
|
||||
// Add a couple more blocks.
|
||||
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
|
|
|
|||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
|
@ -3519,6 +3520,12 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@plausible-analytics/tracker": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
|
||||
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.2.13",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
|
||||
import { StoreService } from './services/store.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
|
||||
@Component({
|
||||
|
|
@ -11,8 +12,10 @@ import { PagesComponent } from './components/pages/pages.component';
|
|||
})
|
||||
export class App implements OnInit {
|
||||
private readonly store = inject(StoreService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.analytics.init();
|
||||
this.store.init();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
} from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { SelectAddComponent } from '../shared/select-add/select-add.component';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
export interface BlockEditSave {
|
||||
|
|
@ -35,9 +34,13 @@ interface EditedValue {
|
|||
@Component({
|
||||
selector: 'lt-block-edit',
|
||||
standalone: true,
|
||||
imports: [SelectAddComponent, ToggleComponent],
|
||||
imports: [SelectAddComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (viewTitle()) {
|
||||
<h2 class="view-title">{{ viewTitle() }}</h2>
|
||||
}
|
||||
|
||||
<section
|
||||
#container
|
||||
class="carousel"
|
||||
|
|
@ -61,7 +64,7 @@ interface EditedValue {
|
|||
class="block-dot"
|
||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||
></div>
|
||||
<h1>{{ formatDate(b.created_at) }}</h1>
|
||||
<h1>{{ formatDate(b.created_at, true) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
|
|
@ -83,14 +86,14 @@ interface EditedValue {
|
|||
(blur)="flushExisting(b.id)"
|
||||
></textarea>
|
||||
|
||||
<div>
|
||||
<lt-toggle
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="editedFor(b.id).is_done"
|
||||
(checkedChange)="updateDone(b.id, $event)"
|
||||
offLabel="This task hasn't been finished yet"
|
||||
onLabel="Goal already accomplished"
|
||||
(change)="updateDone(b.id, $any($event.target).checked)"
|
||||
/>
|
||||
</div>
|
||||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="bottom">
|
||||
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
|
||||
|
|
@ -133,14 +136,14 @@ interface EditedValue {
|
|||
(input)="updateNewDescription($any($event.target).value)"
|
||||
></textarea>
|
||||
|
||||
<div>
|
||||
<lt-toggle
|
||||
<label class="done-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="newValue().is_done"
|
||||
(checkedChange)="updateNewDone($event)"
|
||||
offLabel="This task hasn't been finished yet"
|
||||
onLabel="Goal already accomplished"
|
||||
(change)="updateNewDone($any($event.target).checked)"
|
||||
/>
|
||||
</div>
|
||||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="bottom">
|
||||
<button
|
||||
|
|
@ -166,21 +169,50 @@ interface EditedValue {
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10001; // above modal backdrop (10000)
|
||||
|
||||
@media (max-height: $min-height) {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.view-title {
|
||||
position: fixed;
|
||||
top: var(--large-padding);
|
||||
left: var(--large-padding);
|
||||
right: var(--large-padding);
|
||||
z-index: 10002;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: var(--title-clearance) 0 var(--large-padding);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 7.5vw;
|
||||
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 {
|
||||
|
|
@ -199,10 +231,10 @@ interface EditedValue {
|
|||
max-width: 400px;
|
||||
scroll-snap-align: center;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 85vw;
|
||||
max-width: 85vw;
|
||||
width: min(88vw, 360px);
|
||||
max-width: calc(100vw - (2 * var(--medium-padding)));
|
||||
padding: var(--medium-padding);
|
||||
margin: calc(var(--medium-padding) / 2);
|
||||
margin: 0 calc(var(--small-padding) / 2);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
box-sizing: border-box;
|
||||
|
|
@ -256,8 +288,9 @@ interface EditedValue {
|
|||
width: 60vw;
|
||||
max-width: 60vw;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 7.5vw;
|
||||
max-width: 7.5vw;
|
||||
width: var(--medium-padding);
|
||||
max-width: var(--medium-padding);
|
||||
min-width: var(--medium-padding);
|
||||
}
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
|
@ -266,6 +299,12 @@ interface EditedValue {
|
|||
.header {
|
||||
@include center-child();
|
||||
position: relative;
|
||||
gap: var(--small-padding);
|
||||
|
||||
h1 {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
|
|
@ -289,6 +328,70 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
|
|
@ -306,15 +409,27 @@ interface EditedValue {
|
|||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
lt-select-add, lt-toggle {
|
||||
lt-select-add,
|
||||
.done-checkbox {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
min-height: 42px;
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlockEditComponent implements AfterViewInit {
|
||||
readonly viewTitle = input<string>('');
|
||||
readonly blocks = input.required<Block[]>();
|
||||
readonly activeBlockId = input<string | null>(null);
|
||||
readonly tags = input<string[]>([]);
|
||||
|
|
@ -407,8 +522,16 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
});
|
||||
|
||||
formatDate(ts: number): string {
|
||||
formatDate(ts: number, compact = false): string {
|
||||
const d = new Date(ts * 1000);
|
||||
if (compact) {
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
// e.g. "May 28, 2026, 14:32"
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -31,27 +31,35 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
/* lt-modal host must not occupy a flex slot in its parent — section.modal
|
||||
is position:fixed, but the host element itself would otherwise take a
|
||||
slot and push siblings around when it mounts/unmounts.
|
||||
display: contents removes the host box without affecting descendants. */
|
||||
/* 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 {
|
||||
display: contents;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
section.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include center-child();
|
||||
padding: var(--large-padding);
|
||||
box-sizing: border-box;
|
||||
background: $background-gradient;
|
||||
background: rgba(255, 248, 248, 0.94);
|
||||
transition: opacity 300ms;
|
||||
opacity: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
|
||||
@media (max-height: $min-height) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ export interface PageSettingsResult {
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -101,6 +105,12 @@ export interface PageSettingsResult {
|
|||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const UUIDV4_RE =
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close">✕</button>
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
<h2>Settings</h2>
|
||||
|
||||
@if (page()) {
|
||||
|
|
@ -43,7 +43,9 @@ const UUIDV4_RE =
|
|||
aria-label="Page name"
|
||||
/>
|
||||
|
||||
<div class="toggle-list">
|
||||
<lt-toggle
|
||||
class="setting-toggle"
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="onHideCreateTowerButtonChange($event)"
|
||||
offLabel="Show add-tower button"
|
||||
|
|
@ -51,11 +53,13 @@ const UUIDV4_RE =
|
|||
/>
|
||||
|
||||
<lt-toggle
|
||||
class="setting-toggle"
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="onKeepTasksOpenChange($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="danger" type="button" (click)="deletePage.emit()">
|
||||
Delete this page
|
||||
|
|
@ -68,7 +72,7 @@ const UUIDV4_RE =
|
|||
<section class="account-section">
|
||||
<h3>Account</h3>
|
||||
|
||||
<p class="hint">Your token (keep it secret — it IS your account)</p>
|
||||
<p class="hint">Copy this token to another device to permanently sync your progress</p>
|
||||
<div class="token-row">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -127,17 +131,19 @@ const UUIDV4_RE =
|
|||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--large-padding) 0;
|
||||
padding: 0 36px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--medium-padding) 0;
|
||||
font-size: var(--large-font-size);
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
section {
|
||||
|
|
@ -155,8 +161,37 @@ const UUIDV4_RE =
|
|||
margin: var(--large-padding) 0;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
button:not(.exit) {
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
lt-toggle.setting-toggle {
|
||||
--toggle-label-width: 145px;
|
||||
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: var(--small-padding);
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.035);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--small-font-size);
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.35;
|
||||
color: rgba($text-color, 0.7);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
output,
|
||||
OnInit,
|
||||
inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Tower, HslColor } from '../../models';
|
||||
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
|
||||
|
|
@ -39,12 +41,12 @@ export interface TowerSettingsResult {
|
|||
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ tower() ? 'Save' : 'Create tower' }}
|
||||
</button>
|
||||
|
||||
@if (tower()) {
|
||||
<!-- Editing an existing tower: changes auto-save, so there's no Save
|
||||
button — only the destructive action remains explicit. -->
|
||||
<button type="button" (click)="delete.emit()">Delete tower</button>
|
||||
} @else {
|
||||
<button type="submit" [disabled]="form.invalid">Create tower</button>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
|
|
@ -86,14 +88,14 @@ export interface TowerSettingsResult {
|
|||
border: 0;
|
||||
}
|
||||
|
||||
// Generous gap between the name input and the color picker — the picker
|
||||
// is a substantial control and crowding it against the title looks busy.
|
||||
.picker-row {
|
||||
padding-top: var(--medium-padding);
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
@ -105,6 +107,7 @@ export class TowerSettingsComponent implements OnInit {
|
|||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
|
|
@ -117,17 +120,36 @@ export class TowerSettingsComponent implements OnInit {
|
|||
if (t) {
|
||||
this.form.patchValue({ name: t.name });
|
||||
this.currentColor = { ...t.base_color };
|
||||
|
||||
// Edit mode: persist name changes as they happen. Wire this up *after*
|
||||
// the initial patchValue so seeding the form doesn't fire a save.
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.autoSave());
|
||||
}
|
||||
}
|
||||
|
||||
onColorChange(color: HslColor): void {
|
||||
this.currentColor = color;
|
||||
// In edit mode the picker is a live control — commit each change.
|
||||
if (this.tower()) this.autoSave();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
// Only the create flow reaches here via its Submit button; edit mode
|
||||
// auto-saves (and Enter on the single field is a harmless redundant save).
|
||||
if (this.form.invalid) return;
|
||||
const v = this.form.value;
|
||||
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
|
||||
this.emitSave();
|
||||
}
|
||||
|
||||
/** Emit a save only when the form is valid (skips e.g. an empty name). */
|
||||
private autoSave(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.emitSave();
|
||||
}
|
||||
|
||||
private emitSave(): void {
|
||||
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
@for (tower of page().towers; track tower.id) {
|
||||
<lt-tower
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="modalOpen()"
|
||||
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
||||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
@if (showAddTower()) {
|
||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative; // anchor for absolute-positioned .trash
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
|
@ -18,10 +19,12 @@
|
|||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
transition: box-shadow $short-animation-time;
|
||||
|
||||
|
|
@ -55,6 +58,7 @@
|
|||
max-width: 200px;
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
|
||||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
|
|
@ -84,15 +88,22 @@
|
|||
// without needing !important on every property — only the width triples need it
|
||||
// to beat the per-child-count selectors generated above.
|
||||
@media (max-width: $mobile-width) {
|
||||
--mobile-tower-width: calc(66vw - var(--small-padding));
|
||||
--mobile-tower-side-padding: max(
|
||||
var(--medium-padding),
|
||||
calc((100% - var(--mobile-tower-width)) / 2)
|
||||
);
|
||||
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: var(--mobile-tower-side-padding);
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
// Side padding lets the first and last tower scroll fully into view.
|
||||
padding: 0 var(--medium-padding);
|
||||
padding: 0 var(--mobile-tower-side-padding);
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
|
@ -100,12 +111,16 @@
|
|||
|
||||
// Override the @for width-calc rules above.
|
||||
& > * {
|
||||
width: calc(66vw - var(--small-padding)) !important;
|
||||
max-width: calc(66vw - var(--small-padding)) !important;
|
||||
min-width: calc(66vw - var(--small-padding)) !important;
|
||||
scroll-snap-align: start;
|
||||
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-shrink: 0;
|
||||
}
|
||||
|
||||
& > *:not(:nth-last-child(1)) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
output,
|
||||
signal,
|
||||
inject,
|
||||
HostListener,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
|
|
@ -66,6 +67,7 @@ export class PageComponent {
|
|||
private readonly modalState = inject(ModalStateService);
|
||||
/** True while any lt-modal is mounted — used to lock tower drag. */
|
||||
readonly modalOpen = this.modalState.anyOpen;
|
||||
readonly mobileDragDisabled = signal(this.isMobileViewport());
|
||||
|
||||
readonly showAddTower = signal(false);
|
||||
readonly isDragging = signal(false);
|
||||
|
|
@ -116,6 +118,18 @@ export class PageComponent {
|
|||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.mobileDragDisabled.set(this.isMobileViewport());
|
||||
}
|
||||
|
||||
private isMobileViewport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tower mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddTower(result: TowerSettingsResult): void {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -21,7 +22,8 @@
|
|||
}
|
||||
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ const FIXED_L = 0.55;
|
|||
display: block;
|
||||
padding: var(--medium-padding);
|
||||
@include card();
|
||||
box-shadow: $shadow-border;
|
||||
border: 1px solid rgba($text-color, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba($light-color, 0.7);
|
||||
background-color: rgba($text-color, 0.025);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker {
|
||||
|
|
@ -76,35 +79,40 @@ const FIXED_L = 0.55;
|
|||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
box-shadow: $shadow-border;
|
||||
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow;
|
||||
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
|
||||
transform: scale(1.15);
|
||||
outline: 2px solid $light-color;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
padding: 8px 0;
|
||||
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
height: 16px;
|
||||
border-radius: 1000px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
|
|
@ -119,11 +127,15 @@ const FIXED_L = 0.55;
|
|||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, #{$light-color});
|
||||
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
|
||||
|
|
@ -135,8 +147,8 @@ const FIXED_L = 0.55;
|
|||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--thumb-color, white);
|
||||
border: 2px solid white;
|
||||
|
|
@ -153,7 +165,7 @@ const FIXED_L = 0.55;
|
|||
.preview {
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: $shadow-border;
|
||||
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
signal,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
|
|
@ -19,7 +22,7 @@ import {
|
|||
[class.always-shadow]="alwaysDropShadow()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div class="top" (click)="open.update(v => !v)">
|
||||
<div class="top" (click)="toggleOpen($event)">
|
||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||
</div>
|
||||
|
|
@ -33,7 +36,9 @@ import {
|
|||
(blur)="onRename(item, $any($event.target).value)"
|
||||
/>
|
||||
} @else {
|
||||
<p (click)="onSelectItem(item)">{{ item }}</p>
|
||||
<button class="option" type="button" (click)="onSelectItem(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="add-row">
|
||||
|
|
@ -43,9 +48,20 @@ import {
|
|||
placeholder="Add a 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()) {
|
||||
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
|
||||
<button
|
||||
class="pen"
|
||||
type="button"
|
||||
[class.active]="editing()"
|
||||
(click)="editing.update(v => !v)"
|
||||
>
|
||||
<img src="assets/pen.svg" alt="Edit" />
|
||||
</button>
|
||||
}
|
||||
|
|
@ -58,6 +74,7 @@ import {
|
|||
@import '../../../../library/main';
|
||||
|
||||
$inner-padding: var(--medium-padding);
|
||||
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
|
@ -80,13 +97,22 @@ import {
|
|||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
min-height: 46px;
|
||||
box-sizing: border-box;
|
||||
gap: var(--small-padding);
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
display: block;
|
||||
@include sub-title-text();
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
img.arrow {
|
||||
flex: 0 0 auto;
|
||||
@include square(16px);
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
|
|
@ -98,81 +124,129 @@ import {
|
|||
|
||||
.bottom-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
overflow-y: hidden;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
pointer-events: all;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
padding: $inner-padding;
|
||||
padding-top: 0;
|
||||
@include inner-spacing($inner-padding);
|
||||
padding-top: var(--small-padding);
|
||||
gap: var(--small-padding);
|
||||
// Default (closed) state — also the target of the close transition.
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
transform: translateY(-100%);
|
||||
transform: translateY(-8px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
// Clip the top edge so the panel's shadow can't bleed back up into
|
||||
// the chip area; sides + bottom get a 10px slack for the shadow.
|
||||
clip-path: inset(0 -10px -10px -10px);
|
||||
// Delay the visibility change until after the slide animation finishes
|
||||
// so the panel stays visible while it animates closed.
|
||||
transition:
|
||||
transform $long-animation-time,
|
||||
opacity $long-animation-time,
|
||||
background-color $long-animation-time,
|
||||
box-shadow $long-animation-time,
|
||||
visibility 0s $long-animation-time;
|
||||
|
||||
&.open {
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
background-color: $light-color;
|
||||
box-shadow: $shadow;
|
||||
// Show shadow on left/right/bottom only; clip the top edge so the
|
||||
// shadow doesn't bleed over the seam where .bottom meets .top.
|
||||
clip-path: inset(0 -6px -6px -6px);
|
||||
box-shadow: $dropdown-shadow;
|
||||
// On open, visibility flips immediately (no delay); transform +
|
||||
// colors + shadow animate over $long-animation-time.
|
||||
transition:
|
||||
transform $long-animation-time,
|
||||
opacity $long-animation-time,
|
||||
background-color $long-animation-time,
|
||||
box-shadow $long-animation-time,
|
||||
visibility 0s 0s;
|
||||
}
|
||||
|
||||
p {
|
||||
.option {
|
||||
@include sub-title-text();
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
@include sub-title-text();
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba($text-color, 0.72);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-row {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) { height: 24px; }
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
gap: var(--small-padding);
|
||||
|
||||
input[type='text'] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-bottom: solid 2px transparent;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
border-bottom-color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
position: static;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.add-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&.pen {
|
||||
opacity: 0.25;
|
||||
|
|
@ -184,6 +258,10 @@ import {
|
|||
background: transparent;
|
||||
position: relative;
|
||||
|
||||
// Kill the global button's hover-grow underline pseudo-element
|
||||
// for the icon-only edit control.
|
||||
&:after { content: none; display: none; }
|
||||
|
||||
img {
|
||||
@include square(16px);
|
||||
}
|
||||
|
|
@ -224,16 +302,18 @@ import {
|
|||
width: 100%;
|
||||
@include card();
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
box-shadow $long-animation-time,
|
||||
height $long-animation-time,
|
||||
border-radius $long-animation-time;
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow;
|
||||
// Show shadow on top/left/right only; clip the bottom edge so the
|
||||
// shadow doesn't bleed over the seam where .top meets .bottom.
|
||||
clip-path: inset(-6px -6px 0 -6px);
|
||||
// Same shadow recipe as the panel below so the two halves read as
|
||||
// one continuous card. Clip the bottom edge so this shadow doesn't
|
||||
// bleed across the seam where .top meets .bottom.
|
||||
box-shadow: $dropdown-shadow;
|
||||
clip-path: inset(-10px -10px 0 -10px);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,25 +331,31 @@ import {
|
|||
}
|
||||
}
|
||||
|
||||
// Hover lifts the chip — but only when the dropdown is closed. When
|
||||
// it's open the chip is already showing $dropdown-shadow and a hover
|
||||
// override would make the top heavier than the panel below.
|
||||
&:hover {
|
||||
@media (min-width: $mobile-width) {
|
||||
.background { box-shadow: $shadow; }
|
||||
.background:not(.active) { box-shadow: $shadow; }
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border {
|
||||
.background.active {
|
||||
box-shadow: $shadow-border;
|
||||
clip-path: inset(-6px -6px 0 -6px);
|
||||
clip-path: inset(-10px -10px 0 -10px);
|
||||
}
|
||||
.bottom.open {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border:hover {
|
||||
.background { box-shadow: $shadow-border; }
|
||||
.background:not(.active) { box-shadow: $shadow-border; }
|
||||
}
|
||||
|
||||
&.always-shadow {
|
||||
.background { box-shadow: $shadow; }
|
||||
.background:not(.active) { box-shadow: $shadow; }
|
||||
// When open, clip the bottom so the always-on shadow doesn't bleed
|
||||
// over the seam; restore full shadow when closed.
|
||||
&:has(.bottom.open) .background {
|
||||
|
|
@ -309,6 +395,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
// ── Internal state ─────────────────────────────────────────────────────────
|
||||
readonly open = signal(false);
|
||||
readonly editing = signal(false);
|
||||
private readonly host = inject(ElementRef<HTMLElement>);
|
||||
|
||||
// Resolved values that merge old + new API
|
||||
protected resolvedItems(): string[] {
|
||||
|
|
@ -332,6 +419,20 @@ export class SelectAddComponent implements OnChanges {
|
|||
// Nothing to do — signals handle reactivity
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.open()) return;
|
||||
if (!this.host.nativeElement.contains(event.target as Node)) {
|
||||
this.open.set(false);
|
||||
this.editing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleOpen(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.open.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectItem(item: string): void {
|
||||
this.select.emit(item);
|
||||
// Legacy compat: also emit the index
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ import {
|
|||
$size: 30px;
|
||||
|
||||
@include center-child();
|
||||
@include inner-spacing(var(--medium-padding), $horizontal: true);
|
||||
gap: var(--medium-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 100%;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: contents;
|
||||
|
|
@ -41,7 +46,7 @@ import {
|
|||
// Fixed width (not max-width) so multiple toggles align column-wise
|
||||
// — the thumb position is identical across rows regardless of label.
|
||||
flex: 0 0 auto;
|
||||
width: 4 * $size;
|
||||
width: var(--toggle-label-width, #{4 * $size});
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--small-padding);
|
||||
line-height: 1.3;
|
||||
|
|
@ -50,10 +55,19 @@ import {
|
|||
&.active { font-weight: bold; }
|
||||
&:first-of-type { text-align: right; }
|
||||
&:last-of-type { text-align: left; }
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
input[type='checkbox'] {
|
||||
-webkit-appearance: none;
|
||||
|
|
|
|||
|
|
@ -17,16 +17,26 @@ import { getColorOfTag } from '../../utils/color';
|
|||
<div
|
||||
class="container"
|
||||
[class.show-hover]="pending().length > 0"
|
||||
(click)="expanded.update(v => !v)"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(touchstart)="$event.stopPropagation()"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<p
|
||||
class="header"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<p class="header">
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
<div
|
||||
class="all-task"
|
||||
#all
|
||||
[style.height.px]="expanded() ? all.scrollHeight : 0"
|
||||
class="all-task"
|
||||
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
|
||||
>
|
||||
@for (b of pending(); track b.id) {
|
||||
<div class="task-container">
|
||||
|
|
@ -34,11 +44,15 @@ import { getColorOfTag } from '../../utils/color';
|
|||
type="button"
|
||||
class="tickbox"
|
||||
[style.background-color]="colorOf(b.tag)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); markDone.emit(b)"
|
||||
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
||||
></button>
|
||||
<p
|
||||
[style.color]="colorOf(b.tag)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||
>{{ b.description || b.tag }}</p>
|
||||
</div>
|
||||
|
|
@ -82,10 +96,24 @@ import { getColorOfTag } from '../../utils/color';
|
|||
|
||||
:first-child { margin-top: var(--small-padding); }
|
||||
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
transition: height $long-animation-time;
|
||||
overflow-y: hidden;
|
||||
transition: max-height $long-animation-time;
|
||||
|
||||
/*
|
||||
* Clip during the open/close animation. We animate to the element's
|
||||
* exact content height (read off #all in the template) so tall lists
|
||||
* simply scroll inside the outer .container (max-height: 30vh) —
|
||||
* .all-task itself is NOT a nested scroller. A nested overflow-y:auto
|
||||
* here pops a scrollbar the instant a tickbox grows on hover, because
|
||||
* its scale(1.05) + shadow widen the scrollable-overflow box.
|
||||
*/
|
||||
overflow: hidden;
|
||||
|
||||
// Sideways breathing room so the clip doesn't shear the tickbox's
|
||||
// hover shadow; negative side margins keep rows flush with the header,
|
||||
// and the bottom padding clears the last row's shadow.
|
||||
margin: 0 calc(var(--small-padding) / -2);
|
||||
padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2);
|
||||
|
||||
.task-container {
|
||||
display: flex;
|
||||
|
|
@ -106,14 +134,16 @@ import { getColorOfTag } from '../../utils/color';
|
|||
// done without opening the edit carousel. Hover & focus reveal a
|
||||
// subtle inner check mark.
|
||||
.tickbox {
|
||||
flex: 0 0 auto;
|
||||
all: unset; // strip native button styles
|
||||
flex: 0 0 24px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@include square(24px);
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(20px);
|
||||
@include square(24px);
|
||||
}
|
||||
border-radius: 4px;
|
||||
box-shadow: $shadow-border;
|
||||
|
|
@ -123,14 +153,25 @@ import { getColorOfTag } from '../../utils/color';
|
|||
content: '✓';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/*
|
||||
* Neutralise the global animated-underline bar from
|
||||
* forms.scss (button:after { height: 2px; width: 0->100% on
|
||||
* hover; background-color: $text-color }). The all:unset on the
|
||||
* button does NOT reach the pseudo-element, so without these
|
||||
* resets the bar paints a dark stripe across the top AND
|
||||
* squashes this box to 2px — which centres the glyph near the
|
||||
* top instead of the middle.
|
||||
*/
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: none;
|
||||
@include center-child();
|
||||
color: $light-color;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
|
||||
opacity: 0; // hidden at rest — only revealed on hover/focus/active
|
||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(2px);
|
||||
// The ✓ glyph sits a touch high in its em-box; nudge to optical centre.
|
||||
transform: translateY(1px);
|
||||
transition: opacity $short-animation-time, transform $short-animation-time;
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +183,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
|
||||
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,10 +193,13 @@ import { getColorOfTag } from '../../utils/color';
|
|||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
|
||||
font-size: var(--medium-font-size);
|
||||
font-weight: 500;
|
||||
filter: saturate(0.85) brightness(0.82);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
|
@ -178,6 +222,7 @@ export class TasksComponent {
|
|||
readonly edit = output<Block>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private lastToggleAt = 0;
|
||||
|
||||
constructor() {
|
||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
||||
|
|
@ -193,4 +238,15 @@ export class TasksComponent {
|
|||
colorOf(tag: string): string {
|
||||
return getColorOfTag(tag, this.baseColor());
|
||||
}
|
||||
|
||||
toggleExpanded(event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'touchend') {
|
||||
event.preventDefault();
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastToggleAt < 250) return;
|
||||
this.lastToggleAt = now;
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import {
|
|||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Tower, Block } from '../../models';
|
||||
import { BlockComponent } from '../block/block.component';
|
||||
|
|
@ -26,6 +30,8 @@ interface StyledBlock extends Block {
|
|||
_opacity: string;
|
||||
}
|
||||
|
||||
const BLOCKS_PER_ROW = 6;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-tower',
|
||||
standalone: true,
|
||||
|
|
@ -33,19 +39,50 @@ interface StyledBlock extends Block {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div 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"
|
||||
[class.trash-highlight]="trashHighlight()"
|
||||
>
|
||||
<lt-tasks
|
||||
[pending]="pending()"
|
||||
[baseColor]="tower().base_color"
|
||||
[initiallyOpen]="keepTasksOpen()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(markDone)="onMarkTaskDone($event)"
|
||||
(edit)="onEditBlock($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
#stackZone
|
||||
class="stack-zone"
|
||||
[style.--block-stack-height]="blockStackHeight()"
|
||||
>
|
||||
<img
|
||||
src="assets/plus-sign.svg"
|
||||
class="add-block"
|
||||
alt="Add block"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); openAddBlock()"
|
||||
/>
|
||||
|
||||
|
|
@ -64,20 +101,17 @@ interface StyledBlock extends Block {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
#nameInput
|
||||
type="text"
|
||||
[value]="tower().name"
|
||||
[style.color]="towerNameCss()"
|
||||
(blur)="onRename($event)"
|
||||
(keydown.enter)="nameInput.blur()"
|
||||
/>
|
||||
@if (hiddenBlockCount() > 0) {
|
||||
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editEntry(); as entry) {
|
||||
<lt-modal (close)="closeEdit()">
|
||||
<lt-block-edit
|
||||
[viewTitle]="editViewTitle()"
|
||||
[blocks]="filteredForEntry()"
|
||||
[activeBlockId]="entry.activeId"
|
||||
[tags]="towerTags()"
|
||||
|
|
@ -92,7 +126,12 @@ interface StyledBlock extends Block {
|
|||
|
||||
@if (showSettings()) {
|
||||
<lt-modal (close)="showSettings.set(false)">
|
||||
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
|
||||
<lt-tower-settings
|
||||
[tower]="tower()"
|
||||
(save)="onTowerSave($event)"
|
||||
(delete)="onTowerDelete()"
|
||||
(close)="showSettings.set(false)"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
`,
|
||||
|
|
@ -100,7 +139,9 @@ interface StyledBlock extends Block {
|
|||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
min-height: 0;
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
|
|
@ -140,7 +181,7 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
}
|
||||
|
||||
input {
|
||||
.tower-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -149,8 +190,10 @@ interface StyledBlock extends Block {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
|
|
@ -158,7 +201,14 @@ interface StyledBlock extends Block {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
--block-stack-height: 0px;
|
||||
--add-block-size: 48px;
|
||||
--add-block-clearance: var(--medium-padding);
|
||||
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
|
|
@ -168,7 +218,9 @@ interface StyledBlock extends Block {
|
|||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
padding: var(--small-padding);
|
||||
padding: 0;
|
||||
--add-block-size: 32px;
|
||||
--add-block-clearance: var(--small-padding);
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
|
|
@ -189,16 +241,16 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
|
||||
lt-tasks {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
min-height: 56px;
|
||||
max-height: 30vh;
|
||||
overflow: hidden;
|
||||
max-height: min(30vh, 45%);
|
||||
overflow: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 44px;
|
||||
max-height: 25vh;
|
||||
max-height: min(25vh, 45%);
|
||||
}
|
||||
|
||||
.container {
|
||||
|
|
@ -207,11 +259,11 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
}
|
||||
|
||||
img.add-block {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin: var(--medium-padding) 0;
|
||||
}
|
||||
.stack-zone {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
|
|
@ -231,9 +283,23 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
}
|
||||
|
||||
img.add-block {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 50%;
|
||||
top: max(
|
||||
0px,
|
||||
min(
|
||||
calc(50% - var(--add-block-size) / 2),
|
||||
calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
|
||||
)
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
|
|
@ -263,24 +329,99 @@ interface StyledBlock extends Block {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-blocks {
|
||||
@include small-text();
|
||||
position: absolute;
|
||||
top: calc(100% + var(--small-padding));
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
color: rgba($text-color, 0.72);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tower-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
input[type='text'] {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
font-size: var(--small-font-size);
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
width: 50%;
|
||||
/* 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: 100%;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TowerComponent {
|
||||
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||
readonly tower = input.required<Tower>();
|
||||
/** Set by page component when this tower is being dragged over trash. */
|
||||
|
|
@ -308,6 +449,11 @@ export class TowerComponent {
|
|||
* which list of blocks to show and which one to focus initially. */
|
||||
readonly editEntry = signal<EditEntry | null>(null);
|
||||
readonly showSettings = signal(false);
|
||||
readonly hiddenBlockCount = signal(0);
|
||||
|
||||
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
|
|
@ -324,6 +470,13 @@ export class TowerComponent {
|
|||
return this.tower().blocks.filter(b => b.is_done === isDone);
|
||||
});
|
||||
|
||||
readonly editViewTitle = computed(() => {
|
||||
const entry = this.editEntry();
|
||||
if (!entry) return '';
|
||||
const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks';
|
||||
return `${prefix} of ${this.tower().name}`;
|
||||
});
|
||||
|
||||
/** Unique tags from existing blocks of this tower. */
|
||||
readonly towerTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
|
|
@ -339,6 +492,11 @@ export class TowerComponent {
|
|||
|
||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
||||
readonly blockStackHeight = computed(() => {
|
||||
const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW);
|
||||
const cqw = rows * (100 / BLOCKS_PER_ROW);
|
||||
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
|
||||
});
|
||||
|
||||
private prevDoneIds: string[] = [];
|
||||
private isFirstRun = true;
|
||||
|
|
@ -346,15 +504,69 @@ export class TowerComponent {
|
|||
constructor() {
|
||||
effect(() => {
|
||||
const range = this.dateRange();
|
||||
// Always keep ALL done blocks in the visible list. The date filter
|
||||
// only flips per-block `_anim` so the 1.5s ascend/descend transition
|
||||
// can animate them in and out of the falling stack.
|
||||
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||
// Reconcile all done blocks, then cap the rendered stack to the rows
|
||||
// that fit below the tasks and add button.
|
||||
const allDone = this.tower().blocks.filter((b) => b.is_done);
|
||||
untracked(() => this.reconcile(allDone, range));
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks));
|
||||
});
|
||||
}
|
||||
|
||||
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.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);
|
||||
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 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,
|
||||
): void {
|
||||
const ids = allDone.map((b) => b.id);
|
||||
const prev = this.prevDoneIds;
|
||||
const prevSet = new Set(prev);
|
||||
|
|
@ -393,16 +605,39 @@ export class TowerComponent {
|
|||
});
|
||||
}
|
||||
|
||||
const visibleLimit =
|
||||
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
|
||||
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||
const hiddenCount = Number.isFinite(visibleLimit)
|
||||
? Math.max(0, restingBlocks.length - visibleLimit)
|
||||
: 0;
|
||||
let visibleStyled = styled;
|
||||
if (Number.isFinite(visibleLimit)) {
|
||||
const shownRestingBlocks =
|
||||
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks;
|
||||
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length);
|
||||
const transitioningBlocks =
|
||||
remainingSlots > 0
|
||||
? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots)
|
||||
: [];
|
||||
const shownIds = new Set([
|
||||
...shownRestingBlocks.map((b) => b.id),
|
||||
...transitioningBlocks.map((b) => b.id),
|
||||
]);
|
||||
visibleStyled = styled.filter((b) => shownIds.has(b.id));
|
||||
}
|
||||
this.hiddenBlockCount.set(hiddenCount);
|
||||
|
||||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = styled.find(b => b.id === newId);
|
||||
const newBlock = visibleStyled.find(b => b.id === newId);
|
||||
if (newBlock && inRange(newBlock)) {
|
||||
// Snap newly-added in-range block to start position, then on the next
|
||||
// paint flip it back to rest — that's what makes it visibly fall.
|
||||
newBlock._anim = '';
|
||||
newBlock._transform = 'translateY(500%)';
|
||||
newBlock._opacity = '0';
|
||||
this._visibleBlocks.set(styled);
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
newBlock._anim = 'descend';
|
||||
|
|
@ -417,7 +652,7 @@ export class TowerComponent {
|
|||
}
|
||||
}
|
||||
// existing fall-through path (no growth, first run, or new block out of range):
|
||||
this._visibleBlocks.set(styled);
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
|
|
@ -472,7 +707,8 @@ export class TowerComponent {
|
|||
}
|
||||
|
||||
onTowerSave(result: TowerSettingsResult): void {
|
||||
this.showSettings.set(false);
|
||||
// Tower edits auto-save, so this fires on every change and must NOT close
|
||||
// the modal — the user closes it via the exit button / backdrop.
|
||||
this.updateTower.emit(result);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="welcome-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>Welcome to Life Towers</h2>
|
||||
|
||||
|
|
@ -60,6 +60,11 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: var(--medium-padding);
|
||||
padding: 0 36px;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 28px;
|
||||
}
|
||||
}
|
||||
|
||||
p.lead { color: $text-color; }
|
||||
|
|
@ -85,6 +90,10 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
border-bottom-color: rgba($accent-color, 0.33);
|
||||
&:after { background-color: $accent-color; }
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
|
|||
65
frontend/src/app/services/analytics.service.ts
Normal file
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
|
|
@ -55,9 +56,24 @@ interface PendingPut {
|
|||
tree: TreeDto;
|
||||
}
|
||||
|
||||
interface ExampleBlockSeed {
|
||||
tag: string;
|
||||
desc: string;
|
||||
done: boolean;
|
||||
ageHrs: number;
|
||||
}
|
||||
|
||||
interface ExampleDonePattern {
|
||||
tag: string;
|
||||
desc: (sequence: number) => string;
|
||||
}
|
||||
|
||||
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StoreService implements OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
private readonly _pages = signal<Page[]>([]);
|
||||
|
|
@ -226,6 +242,8 @@ export class StoreService implements OnDestroy {
|
|||
towers: [],
|
||||
};
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackPageCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +274,8 @@ export class StoreService implements OnDestroy {
|
|||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackTowerCreated();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -318,6 +338,8 @@ export class StoreService implements OnDestroy {
|
|||
: p,
|
||||
),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackBlockCreated({ isDone: is_done });
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +358,7 @@ export class StoreService implements OnDestroy {
|
|||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
|
||||
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
|
|
@ -366,6 +388,7 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
let becameDone = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
|
|
@ -373,21 +396,58 @@ export class StoreService implements OnDestroy {
|
|||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) =>
|
||||
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
|
||||
),
|
||||
}
|
||||
? (() => {
|
||||
const block = t.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return t;
|
||||
const result = this.patchBlockList(t.blocks, blockId, {
|
||||
is_done: !block.is_done,
|
||||
});
|
||||
becameDone = result.becameDone;
|
||||
return { ...t, blocks: result.blocks };
|
||||
})()
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
if (becameDone) this.analytics.trackBlockCompleted();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private patchBlockList(
|
||||
blocks: Block[],
|
||||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): { blocks: Block[]; becameDone: boolean } {
|
||||
const nextBlocks: Block[] = [];
|
||||
let completedBlock: Block | null = null;
|
||||
let becameDone = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.id !== blockId) {
|
||||
nextBlocks.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated: Block = { ...block, ...patch };
|
||||
becameDone = !block.is_done && updated.is_done;
|
||||
if (becameDone) {
|
||||
// Display newly completed todos as the newest square, without changing
|
||||
// created_at because the date slider still filters on creation time.
|
||||
completedBlock = updated;
|
||||
} else {
|
||||
nextBlocks.push(updated);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks,
|
||||
becameDone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different user's token. Any pending writes for the OLD
|
||||
* account must be cancelled first — otherwise a queued PUT could fire
|
||||
|
|
@ -529,30 +589,56 @@ export class StoreService implements OnDestroy {
|
|||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
// Done blocks are listed oldest-first so they fill the falling stack
|
||||
// oldest → newest (left → right), matching the slider's old → new
|
||||
// labels; pending tasks stay on top for the accordion.
|
||||
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
|
||||
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
|
||||
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 },
|
||||
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
|
||||
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
|
||||
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'novel', desc: (n) => `Read chapter ${n}` },
|
||||
{ tag: 'paper', desc: (n) => `Annotated paper ${n}` },
|
||||
{ tag: 'article', desc: (n) => `Saved article notes ${n}` },
|
||||
{ tag: 'essay', desc: (n) => `Drafted reading response ${n}` },
|
||||
],
|
||||
2,
|
||||
8,
|
||||
),
|
||||
]),
|
||||
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
|
||||
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
|
||||
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
|
||||
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
|
||||
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'angular', desc: (n) => `Refined UI pass ${n}` },
|
||||
{ tag: 'rust', desc: (n) => `Completed systems spike ${n}` },
|
||||
{ tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` },
|
||||
{ tag: 'docs', desc: (n) => `Captured project note ${n}` },
|
||||
],
|
||||
4,
|
||||
9,
|
||||
),
|
||||
]),
|
||||
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
|
||||
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
|
||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
|
||||
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
|
||||
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
|
||||
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
|
||||
...this.makeExampleDoneBlocks(
|
||||
[
|
||||
{ tag: 'run', desc: (n) => `Easy run ${n}` },
|
||||
{ tag: 'climb', desc: (n) => `Bouldering session ${n}` },
|
||||
{ tag: 'mobility', desc: (n) => `Mobility circuit ${n}` },
|
||||
{ tag: 'strength', desc: (n) => `Strength session ${n}` },
|
||||
],
|
||||
6,
|
||||
11,
|
||||
),
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackExampleLoaded();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +646,7 @@ export class StoreService implements OnDestroy {
|
|||
name: string,
|
||||
base_color: HslColor,
|
||||
nowSec: number,
|
||||
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
|
||||
blocks: ExampleBlockSeed[],
|
||||
): Tower {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
|
|
@ -576,6 +662,23 @@ export class StoreService implements OnDestroy {
|
|||
};
|
||||
}
|
||||
|
||||
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,
|
||||
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingWrites();
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,87 @@ describe('StoreService', () => {
|
|||
expect(api.getData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('moves a pending block to the end when it becomes done', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.getData.mockResolvedValue({
|
||||
pages: [
|
||||
{
|
||||
id: 'page-1',
|
||||
name: 'Page',
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
{
|
||||
id: 'tower-1',
|
||||
name: 'Tower',
|
||||
base_color: { h: 0.5, s: 0.5, l: 0.5 },
|
||||
blocks: [
|
||||
{
|
||||
id: 'old-pending',
|
||||
tag: 'a',
|
||||
description: 'Created first, completed last',
|
||||
is_done: false,
|
||||
created_at: 100,
|
||||
},
|
||||
{
|
||||
id: 'newer-pending',
|
||||
tag: 'b',
|
||||
description: 'Still pending',
|
||||
is_done: false,
|
||||
created_at: 300,
|
||||
},
|
||||
{
|
||||
id: 'existing-done',
|
||||
tag: 'c',
|
||||
description: 'Already done',
|
||||
is_done: true,
|
||||
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 hundreds of completed squares', () => {
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
store.loadExample();
|
||||
|
||||
const [page] = store.pages();
|
||||
expect(page.name).toBe('Hobbies');
|
||||
expect(page.towers).toHaveLength(3);
|
||||
|
||||
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
|
||||
expect(doneBlocks.length).toBeGreaterThanOrEqual(300);
|
||||
|
||||
for (const tower of page.towers) {
|
||||
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
|
||||
expect(doneDates.length).toBeGreaterThanOrEqual(100);
|
||||
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
|
||||
}
|
||||
|
||||
store.ngOnDestroy();
|
||||
});
|
||||
|
||||
// ── Debounced save ─────────────────────────────────────────────────────────
|
||||
|
||||
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,24 @@
|
|||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
|
||||
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:title" content="Life Towers" />
|
||||
<meta property="og:description" content="Organise your tasks into visual towers of blocks." />
|
||||
<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:image" content="og-image.png" />
|
||||
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:site_name" content="Life Towers" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta property="og:image:secure_url" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="3333" />
|
||||
<meta property="og:image:height" content="1750" />
|
||||
<meta property="og:image:alt" content="Life Towers task towers preview" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Life Towers" />
|
||||
<meta name="twitter:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta name="twitter:image:alt" content="Life Towers task towers preview" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ input[type='text'] {
|
|||
&:focus {
|
||||
box-shadow: 0 1px $text-color;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 2px $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -56,6 +60,19 @@ button {
|
|||
border-bottom: solid $height #5d576b55;
|
||||
position: relative;
|
||||
|
||||
// Mobile: grow buttons to a ~42px tap target, but pin the label to the bottom
|
||||
// so the bottom-border underline keeps hugging it (as on desktop). A bare
|
||||
// min-height vertically centres the text and strands the underline well below
|
||||
// it. Using flex (not a hit-area pseudo-element) means buttons that `all: unset`
|
||||
// their styling — .tickbox, .swatch, .edit-tower — reset these props and opt out
|
||||
// automatically, instead of inheriting a stray absolute overlay.
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #5d576b55;
|
||||
border-bottom: solid $height #5d576b33;
|
||||
|
|
|
|||
|
|
@ -34,12 +34,31 @@
|
|||
|
||||
@mixin exit {
|
||||
@include square(16px);
|
||||
background: url('/assets/x-sign.svg') no-repeat center center;
|
||||
background-size: 50% 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent url('/assets/x-sign.svg') no-repeat center center / 50% 50%;
|
||||
border: 0 !important;
|
||||
border-bottom: 0 !important;
|
||||
box-shadow: none;
|
||||
box-sizing: content-box;
|
||||
color: transparent;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
text-decoration: none !important;
|
||||
|
||||
@include jump();
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: none !important;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,22 @@
|
|||
margin-bottom: $spacing;
|
||||
}
|
||||
}
|
||||
|
||||
& > lt-modal {
|
||||
@if $horizontal {
|
||||
margin-right: 0 !important;
|
||||
} @else {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed modal hosts should not make the previous content child count as
|
||||
// "not last" for spacing.
|
||||
& > *:not(:last-child):not(lt-modal):not(:has(~ *:not(lt-modal))) {
|
||||
@if $horizontal {
|
||||
margin-right: 0;
|
||||
} @else {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// The package ships only a `module` field, which Vite's resolver
|
||||
// can't always find — point it at the file directly.
|
||||
'@plausible-analytics/tracker':
|
||||
'@plausible-analytics/tracker/plausible.js',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue