Clean up
This commit is contained in:
parent
3930982bd8
commit
ad7968dadd
53 changed files with 564 additions and 1013 deletions
|
|
@ -44,6 +44,7 @@ USER appuser
|
||||||
|
|
||||||
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
||||||
LIFE_TOWERS_STATIC_DIR=/app/static \
|
LIFE_TOWERS_STATIC_DIR=/app/static \
|
||||||
|
LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONPATH=/app/src \
|
PYTHONPATH=/app/src \
|
||||||
PATH=/app/.venv/bin:$PATH
|
PATH=/app/.venv/bin:$PATH
|
||||||
|
|
@ -53,4 +54,4 @@ EXPOSE 8000
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
CMD ["uvicorn", "life_towers.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
CMD ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"]
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ Then visit http://localhost:8000.
|
||||||
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
@ -92,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Forgejo CI (`.forgejo/workflows/ci.yml`) builds and tests the backend, frontend, and Docker image on every push to `master`. On successful push to `master` it also tags and pushes the image to a registry — configure `REGISTRY_URL`, `REGISTRY_USER`, and `REGISTRY_PASSWORD` as repository secrets.
|
Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`.
|
||||||
|
|
||||||
The deploy workflow (`.forgejo/workflows/deploy.yml`) triggers on `workflow_dispatch` or a `v*` tag push. It SSHs into the target server and runs `docker compose pull && docker compose up -d`, then polls the healthcheck. The server must have a `.env` file alongside `docker-compose.yml` that pins `LIFE_TOWERS_IMAGE` to the registry tag pushed by CI — otherwise `docker compose pull` is a no-op against the placeholder `life-towers:local`. Configure `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and `DEPLOY_PATH` as repository secrets before use.
|
The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-<commit>` on pushes to `master` or manual dispatch. It publishes to `vars.REGISTRY` (default `ghcr.io`) under the repository name, using `secrets.GITHUB_TOKEN` for registry auth.
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,6 @@ dependencies = [
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0.0",
|
|
||||||
"pytest-asyncio>=0.23.0",
|
|
||||||
"httpx>=0.27.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""APIRouter with all Life Towers endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
|
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from .auth import get_current_user
|
from .auth import get_current_user
|
||||||
from .db import db_connection
|
from .db import db_connection
|
||||||
from .limits import limiter
|
from .limits import limiter
|
||||||
|
from .logging import token_log_id
|
||||||
from .models import (
|
from .models import (
|
||||||
BlockOut,
|
BlockOut,
|
||||||
DataIn,
|
DataIn,
|
||||||
|
|
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
||||||
(now, token),
|
(now, token),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("user_registered", user_id=token, new=existing is None)
|
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
|
||||||
return RegisterResponse(user_id=token)
|
return RegisterResponse(user_id=token)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -239,8 +238,14 @@ async def put_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
conn.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Submitted IDs conflict with existing data",
|
||||||
|
) from exc
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ def get_current_user(request: Request) -> str:
|
||||||
u = uuid.UUID(token)
|
u = uuid.UUID(token)
|
||||||
if u.version != 4:
|
if u.version != 4:
|
||||||
raise ValueError("Not v4")
|
raise ValueError("Not v4")
|
||||||
|
token = str(u)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
raise _unauthorized()
|
raise _unauthorized()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import uuid as _uuid_mod
|
import uuid as _uuid_mod
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
@ -26,8 +27,12 @@ def configure_logging() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def token_log_id(token: str) -> str:
|
||||||
|
return sha256(token.encode("utf-8")).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
"""Log each request without writing bearer credentials to the log stream."""
|
||||||
request_id = str(_uuid_mod.uuid4())
|
request_id = str(_uuid_mod.uuid4())
|
||||||
structlog.contextvars.clear_contextvars()
|
structlog.contextvars.clear_contextvars()
|
||||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||||
|
|
@ -38,7 +43,7 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||||
if auth:
|
if auth:
|
||||||
parts = auth.split()
|
parts = auth.split()
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
user_id = parts[1]
|
user_id = token_log_id(parts[1])
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
"""ASGI app, lifespan, static files mount, route registration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
@ -37,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
|
||||||
413: "payload_too_large",
|
413: "payload_too_large",
|
||||||
422: "bad_request",
|
422: "bad_request",
|
||||||
429: "rate_limited",
|
429: "rate_limited",
|
||||||
507: "quota_exceeded",
|
|
||||||
500: "server_error",
|
500: "server_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,7 +90,12 @@ def create_app() -> FastAPI:
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
fields = sorted(
|
fields = sorted(
|
||||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
field
|
||||||
|
for field in {
|
||||||
|
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
|
||||||
|
for e in exc.errors()
|
||||||
|
}
|
||||||
|
if field
|
||||||
)
|
)
|
||||||
if fields:
|
if fields:
|
||||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
import uuid as _uuid_mod
|
import uuid as _uuid_mod
|
||||||
|
|
||||||
|
|
||||||
def _is_uuidv4(value: str) -> bool:
|
def _canonical_uuidv4(value: str) -> str:
|
||||||
try:
|
try:
|
||||||
u = _uuid_mod.UUID(value)
|
u = _uuid_mod.UUID(value)
|
||||||
return u.version == 4
|
if u.version == 4:
|
||||||
|
return str(u)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return False
|
pass
|
||||||
|
raise ValueError("must be a UUIDv4")
|
||||||
|
|
||||||
|
|
||||||
class HslColor(BaseModel):
|
class HslColor(BaseModel):
|
||||||
|
|
@ -33,9 +35,7 @@ class BlockIn(BaseModel):
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class BlockOut(BaseModel):
|
class BlockOut(BaseModel):
|
||||||
|
|
@ -56,9 +56,7 @@ class TowerIn(BaseModel):
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class TowerOut(BaseModel):
|
class TowerOut(BaseModel):
|
||||||
|
|
@ -80,9 +78,7 @@ class PageIn(BaseModel):
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_id(cls, v: str) -> str:
|
def validate_id(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class PageOut(BaseModel):
|
class PageOut(BaseModel):
|
||||||
|
|
@ -139,9 +135,7 @@ class RegisterRequest(BaseModel):
|
||||||
@field_validator("token")
|
@field_validator("token")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_token(cls, v: str) -> str:
|
def validate_token(cls, v: str) -> str:
|
||||||
if not _is_uuidv4(v):
|
return _canonical_uuidv4(v)
|
||||||
raise ValueError("token must be a UUIDv4")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterResponse(BaseModel):
|
class RegisterResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,21 @@ async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None:
|
||||||
|
token = make_uuidv4()
|
||||||
|
upper_token = token.upper()
|
||||||
|
register_resp = await client.post("/api/v1/register", json={"token": upper_token})
|
||||||
|
assert register_resp.status_code == 200
|
||||||
|
assert register_resp.json() == {"user_id": token}
|
||||||
|
|
||||||
|
data_resp = await client.get(
|
||||||
|
"/api/v1/data",
|
||||||
|
headers={"Authorization": f"Bearer {upper_token}"},
|
||||||
|
)
|
||||||
|
assert data_resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth / GET /data
|
# Auth / GET /data
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -322,6 +337,34 @@ async def test_put_duplicate_page_id(client: AsyncClient) -> None:
|
||||||
|
|
||||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||||
|
assert resp.json() == {"error": "bad_request", "detail": "Validation failed"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None:
|
||||||
|
first_token = make_uuidv4()
|
||||||
|
second_token = make_uuidv4()
|
||||||
|
await client.post("/api/v1/register", json={"token": first_token})
|
||||||
|
await client.post("/api/v1/register", json={"token": second_token})
|
||||||
|
|
||||||
|
tree = _make_tree()
|
||||||
|
first_resp = await client.put(
|
||||||
|
"/api/v1/data",
|
||||||
|
json=tree,
|
||||||
|
headers={"Authorization": f"Bearer {first_token}"},
|
||||||
|
)
|
||||||
|
assert first_resp.status_code == 204
|
||||||
|
|
||||||
|
second_resp = await client.put(
|
||||||
|
"/api/v1/data",
|
||||||
|
json=tree,
|
||||||
|
headers={"Authorization": f"Bearer {second_token}"},
|
||||||
|
)
|
||||||
|
assert second_resp.status_code == 409
|
||||||
|
assert second_resp.json() == {
|
||||||
|
"error": "conflict",
|
||||||
|
"detail": "Submitted IDs conflict with existing data",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
|
|
@ -201,13 +201,6 @@ dependencies = [
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-asyncio" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|
@ -218,15 +211,11 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.111.0" },
|
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
|
||||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||||
{ name = "structlog", specifier = ">=24.1.0" },
|
{ name = "structlog", specifier = ">=24.1.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,11 @@
|
||||||
# Frontend
|
# Life Towers Frontend
|
||||||
|
|
||||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13.
|
Angular frontend for the Life Towers app. See the root `README.md` for the full
|
||||||
|
stack setup.
|
||||||
## Development server
|
|
||||||
|
|
||||||
To start a local development server, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ng serve
|
npm start # dev server on :4200, with /api proxied to :8000
|
||||||
|
npm test # Vitest unit tests
|
||||||
|
npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000
|
||||||
|
npm run build # production bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
|
||||||
|
|
||||||
## Code scaffolding
|
|
||||||
|
|
||||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate component component-name
|
|
||||||
```
|
|
||||||
|
|
||||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To build the project run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
|
||||||
|
|
||||||
## Running unit tests
|
|
||||||
|
|
||||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running end-to-end tests
|
|
||||||
|
|
||||||
For end-to-end (e2e) testing, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,6 @@
|
||||||
"proxyConfig": "proxy.conf.json"
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
|
||||||
"builder": "@angular/build:unit-test"
|
|
||||||
},
|
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-eslint/builder:lint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
"options": {
|
"options": {
|
||||||
|
|
@ -92,4 +89,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,11 @@ test.describe('Life Towers smoke test', () => {
|
||||||
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
|
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Wait for the empty-state hint that means init() completed.
|
// Wait for init, then dismiss the welcome modal so the page controls are reachable.
|
||||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||||
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
|
await expect(page.getByText('Add a new page to get started!')).toBeVisible();
|
||||||
|
|
||||||
// Create a page via the select-add dropdown.
|
// Create a page via the select-add dropdown.
|
||||||
await page.locator('lt-select-add .top').first().click();
|
await page.locator('lt-select-add .top').first().click();
|
||||||
|
|
@ -24,7 +27,7 @@ test.describe('Life Towers smoke test', () => {
|
||||||
|
|
||||||
// Create a tower.
|
// Create a tower.
|
||||||
await page.locator('img[alt="Add tower"]').click();
|
await page.locator('img[alt="Add tower"]').click();
|
||||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
await page.locator('input[placeholder="New tower"]').fill('Side projects');
|
||||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||||
|
|
||||||
// Tower's name input is rendered with the tower name as its value.
|
// Tower's name input is rendered with the tower name as its value.
|
||||||
|
|
@ -43,27 +46,25 @@ test.describe('Life Towers smoke test', () => {
|
||||||
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
||||||
'Modernise the towers app',
|
'Modernise the towers app',
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
await page.getByLabel('Already done').uncheck();
|
||||||
|
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||||
|
|
||||||
// New block is pending → appears in the tasks accordion.
|
// New block is pending → appears in the tasks accordion.
|
||||||
// (Tasks header shows N tasks.)
|
// (Tasks header shows N tasks.)
|
||||||
await expect(page.locator('lt-tasks')).toContainText('1');
|
await expect(page.locator('lt-tasks')).toContainText('1');
|
||||||
|
|
||||||
// Open the tasks accordion + click the task to edit it, then flip done.
|
// Open the tasks accordion + click the task to edit it, then flip done.
|
||||||
await page.locator('lt-tasks .container').click();
|
await page.locator('lt-tasks .header').click();
|
||||||
await page.locator('lt-tasks .task-container').click();
|
await page.locator('lt-tasks .task-description').click();
|
||||||
|
|
||||||
// Toggle done in the block-edit modal — the right label is "Done".
|
// Toggle done in the block-edit modal.
|
||||||
const putLanded = page.waitForResponse(
|
const putLanded = page.waitForResponse(
|
||||||
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
|
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
|
||||||
);
|
);
|
||||||
// Toggle uses verbose labels — "Goal accomplished" flips it to done.
|
await page.locator('lt-block-edit .card.active').getByLabel('Already done').check();
|
||||||
await page
|
|
||||||
.locator('lt-block-edit lt-toggle span')
|
|
||||||
.filter({ hasText: 'Goal accomplished' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
|
||||||
await putLanded;
|
await putLanded;
|
||||||
|
await page.locator('lt-block-edit .card.active .exit').click();
|
||||||
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
|
|
||||||
// Done block now appears as a colored square in the tower's falling stack.
|
// Done block now appears as a colored square in the tower's falling stack.
|
||||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||||
|
|
@ -73,7 +74,7 @@ test.describe('Life Towers smoke test', () => {
|
||||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
|
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
await expect(page.getByDisplayValue('Side projects')).toBeVisible();
|
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
|
||||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { test } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
process.env['CAPTURE_VISUALS'] !== '1',
|
||||||
|
'Set CAPTURE_VISUALS=1 to run the visual screenshot capture suite.',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visual capture: drives the UI into key states and writes screenshots
|
* Visual capture: drives the UI into key states and writes screenshots
|
||||||
* for human review of the legacy-styled design.
|
* for human review of the legacy-styled design.
|
||||||
|
|
@ -55,12 +60,12 @@ test.describe('Life Towers visuals', () => {
|
||||||
.fill('Finish The Brothers Karamazov');
|
.fill('Finish The Brothers Karamazov');
|
||||||
// Uncheck "Already done" so this becomes a pending task.
|
// Uncheck "Already done" so this becomes a pending task.
|
||||||
await createCard.getByLabel('Already done').uncheck();
|
await createCard.getByLabel('Already done').uncheck();
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
|
|
||||||
// Open the tasks accordion to show the new tickbox.
|
// Open the tasks accordion to show the new tickbox.
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await page.locator('lt-tasks .container').click();
|
await page.locator('lt-tasks .header').click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
||||||
|
|
||||||
|
|
@ -83,7 +88,7 @@ test.describe('Life Towers visuals', () => {
|
||||||
await cc.locator('lt-select-add .top').click();
|
await cc.locator('lt-select-add .top').click();
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
|
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
|
||||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,9 +201,9 @@ test.describe('Life Towers visuals', () => {
|
||||||
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
|
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
|
||||||
|
|
||||||
// Open the block-edit carousel for the first tower's first task.
|
// Open the block-edit carousel for the first tower's first task.
|
||||||
await page.locator('lt-tasks .container').first().click();
|
await page.locator('lt-tasks .header').first().click();
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
await page.locator('lt-tasks .task-container').first().click();
|
await page.locator('lt-tasks .task-description').first().click();
|
||||||
await page.waitForSelector('section.modal.active');
|
await page.waitForSelector('section.modal.active');
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
|
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
|
||||||
|
|
|
||||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
|
|
@ -14,7 +14,6 @@
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
"@plausible-analytics/tracker": "^0.4.5",
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
|
@ -719,24 +718,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/router": {
|
|
||||||
"version": "21.2.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz",
|
|
||||||
"integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@angular/common": "21.2.14",
|
|
||||||
"@angular/core": "21.2.14",
|
|
||||||
"@angular/platform-browser": "21.2.14",
|
|
||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular/service-worker": {
|
"node_modules/@angular/service-worker": {
|
||||||
"version": "21.2.14",
|
"version": "21.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
isDevMode,
|
isDevMode,
|
||||||
provideZonelessChangeDetection,
|
provideZonelessChangeDetection,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
|
||||||
|
|
@ -12,7 +11,6 @@ export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter([]),
|
|
||||||
provideHttpClient(withFetch()),
|
provideHttpClient(withFetch()),
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
|
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
|
||||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
|
||||||
--french-violet: oklch(47.66% 0.246 305.88);
|
|
||||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
|
||||||
--hot-red: oklch(61.42% 0.238 15.34);
|
|
||||||
--orange-red: oklch(63.32% 0.24 31.68);
|
|
||||||
|
|
||||||
--gray-900: oklch(19.37% 0.006 300.98);
|
|
||||||
--gray-700: oklch(36.98% 0.014 302.71);
|
|
||||||
--gray-400: oklch(70.9% 0.015 304.04);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
box-sizing: border-box;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
display: block;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.125rem;
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 100%;
|
|
||||||
letter-spacing: -0.125rem;
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.angular-logo {
|
|
||||||
max-width: 9.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
|
||||||
margin-inline: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
|
||||||
color: var(--pill-accent);
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
padding-block: 0.375rem;
|
|
||||||
border-radius: 2.75rem;
|
|
||||||
border: 0;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
font-family: var(--inter-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4rem;
|
|
||||||
letter-spacing: -0.00875rem;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 1) {
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 2) {
|
|
||||||
--pill-accent: var(--electric-violet);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 3) {
|
|
||||||
--pill-accent: var(--french-violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 4),
|
|
||||||
.pill-group .pill:nth-child(6n + 5),
|
|
||||||
.pill-group .pill:nth-child(6n + 6) {
|
|
||||||
--pill-accent: var(--hot-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group svg {
|
|
||||||
margin-inline-start: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.73rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links path {
|
|
||||||
transition: fill 0.3s ease;
|
|
||||||
fill: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links a:hover svg path {
|
|
||||||
fill: var(--gray-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.content {
|
|
||||||
flex-direction: column;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
|
||||||
margin-block: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<div class="content">
|
|
||||||
<div class="left-side">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 982 239"
|
|
||||||
fill="none"
|
|
||||||
class="angular-logo"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path
|
|
||||||
fill="url(#b)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#c)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<radialGradient
|
|
||||||
id="c"
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="1"
|
|
||||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#FF41F8" />
|
|
||||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
|
||||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="b"
|
|
||||||
x1="0"
|
|
||||||
x2="982"
|
|
||||||
y1="192"
|
|
||||||
y2="192"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#F0060B" />
|
|
||||||
<stop offset="0" stop-color="#F0070C" />
|
|
||||||
<stop offset=".526" stop-color="#CC26D5" />
|
|
||||||
<stop offset="1" stop-color="#7702FF" />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
<h1>Hello, {{ title() }}</h1>
|
|
||||||
<p>Congratulations! Your app is running. 🎉</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
|
||||||
<div class="right-side">
|
|
||||||
<div class="pill-group">
|
|
||||||
@for (item of [
|
|
||||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
|
||||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
|
||||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
|
||||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
|
||||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
|
||||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
|
||||||
]; track item.title) {
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
[href]="item.link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
width="14"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="social-links">
|
|
||||||
<a
|
|
||||||
href="https://github.com/angular/angular"
|
|
||||||
aria-label="Github"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="25"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 25 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Github"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://x.com/angular"
|
|
||||||
aria-label="X"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="X"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
|
||||||
aria-label="Youtube"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="29"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 29 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Youtube"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,6 +16,6 @@ export class App implements OnInit {
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.analytics.init();
|
this.analytics.init();
|
||||||
this.store.init();
|
void this.store.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
inject,
|
|
||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
|
@ -33,6 +32,10 @@ interface EditedValue {
|
||||||
difficulty: number;
|
difficulty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampDifficulty(value: number): number {
|
||||||
|
return Math.max(1, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lt-block-edit',
|
selector: 'lt-block-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -48,6 +51,8 @@ interface EditedValue {
|
||||||
class="carousel"
|
class="carousel"
|
||||||
(scroll)="onScroll()"
|
(scroll)="onScroll()"
|
||||||
(click)="onBackdropClick($event)"
|
(click)="onBackdropClick($event)"
|
||||||
|
(keydown.enter)="onBackdropClick($any($event))"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="card placeholder"></div>
|
<div class="card placeholder"></div>
|
||||||
|
|
||||||
|
|
@ -56,12 +61,22 @@ interface EditedValue {
|
||||||
class="card"
|
class="card"
|
||||||
[class.active]="activeIdx() === i + 1"
|
[class.active]="activeIdx() === i + 1"
|
||||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
||||||
(click)="onCardClick(i + 1)"
|
(click)="onCardClick(i + 1)"
|
||||||
|
(keydown.enter)="onCardClick(i + 1)"
|
||||||
|
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||||
>
|
>
|
||||||
<div class="mask"></div>
|
<div class="mask"></div>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
<button
|
||||||
|
class="exit"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
(click)="close.emit(); $event.stopPropagation()"
|
||||||
|
></button>
|
||||||
<div
|
<div
|
||||||
class="block-dot"
|
class="block-dot"
|
||||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||||
|
|
@ -83,6 +98,7 @@ interface EditedValue {
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Write a description here…"
|
placeholder="Write a description here…"
|
||||||
|
maxlength="10000"
|
||||||
[value]="editedFor(b.id).description"
|
[value]="editedFor(b.id).description"
|
||||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||||
(blur)="flushExisting(b.id)"
|
(blur)="flushExisting(b.id)"
|
||||||
|
|
@ -112,6 +128,7 @@ interface EditedValue {
|
||||||
type="button"
|
type="button"
|
||||||
class="step"
|
class="step"
|
||||||
aria-label="Increase difficulty"
|
aria-label="Increase difficulty"
|
||||||
|
[disabled]="editedFor(b.id).difficulty >= 100"
|
||||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,12 +144,22 @@ interface EditedValue {
|
||||||
class="card create-card"
|
class="card create-card"
|
||||||
[class.active]="activeIdx() === blocks().length + 1"
|
[class.active]="activeIdx() === blocks().length + 1"
|
||||||
[class.near-active]="activeIdx() === blocks().length"
|
[class.near-active]="activeIdx() === blocks().length"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Focus create card"
|
||||||
(click)="onCardClick(blocks().length + 1)"
|
(click)="onCardClick(blocks().length + 1)"
|
||||||
|
(keydown.enter)="onCardClick(blocks().length + 1)"
|
||||||
|
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
|
||||||
>
|
>
|
||||||
<div class="mask"></div>
|
<div class="mask"></div>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
<button
|
||||||
|
class="exit"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
(click)="close.emit(); $event.stopPropagation()"
|
||||||
|
></button>
|
||||||
<div
|
<div
|
||||||
class="block-dot"
|
class="block-dot"
|
||||||
[style.background-color]="colorOfNewTag()"
|
[style.background-color]="colorOfNewTag()"
|
||||||
|
|
@ -140,7 +167,7 @@ interface EditedValue {
|
||||||
<h1>Create now</h1>
|
<h1>Create now</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="select-add-container">
|
<div class="select-add-container" [class.required-empty]="!newValue().tag">
|
||||||
<lt-select-add
|
<lt-select-add
|
||||||
[items]="tags()"
|
[items]="tags()"
|
||||||
[selected]="newValue().tag"
|
[selected]="newValue().tag"
|
||||||
|
|
@ -154,6 +181,7 @@ interface EditedValue {
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Write a description here…"
|
placeholder="Write a description here…"
|
||||||
|
maxlength="10000"
|
||||||
[value]="newValue().description"
|
[value]="newValue().description"
|
||||||
(input)="updateNewDescription($any($event.target).value)"
|
(input)="updateNewDescription($any($event.target).value)"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -182,6 +210,7 @@ interface EditedValue {
|
||||||
type="button"
|
type="button"
|
||||||
class="step"
|
class="step"
|
||||||
aria-label="Increase difficulty"
|
aria-label="Increase difficulty"
|
||||||
|
[disabled]="newValue().difficulty >= 100"
|
||||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -671,7 +700,7 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDifficulty(id: string, delta: number): void {
|
updateDifficulty(id: string, delta: number): void {
|
||||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
const next = clampDifficulty(this.editedFor(id).difficulty + delta);
|
||||||
this.patchEdited(id, { difficulty: next });
|
this.patchEdited(id, { difficulty: next });
|
||||||
this.flushExisting(id);
|
this.flushExisting(id);
|
||||||
}
|
}
|
||||||
|
|
@ -717,7 +746,7 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNewDifficulty(delta: number): void {
|
updateNewDifficulty(delta: number): void {
|
||||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
submitNew(): void {
|
submitNew(): void {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
||||||
class="modal"
|
class="modal"
|
||||||
[class.active]="active()"
|
[class.active]="active()"
|
||||||
(click)="onBackdropClick($event)"
|
(click)="onBackdropClick($event)"
|
||||||
|
(keydown.enter)="onBackdropClick($any($event))"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="modal__dialog"
|
class="modal__dialog"
|
||||||
|
|
@ -94,7 +96,6 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||||
private previousFocus: HTMLElement | null = null;
|
private previousFocus: HTMLElement | null = null;
|
||||||
private escListener!: (e: KeyboardEvent) => void;
|
|
||||||
private readonly modalState = inject(ModalStateService);
|
private readonly modalState = inject(ModalStateService);
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
OnInit,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { Page } from '../../models';
|
|
||||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
|
||||||
|
|
||||||
export interface PageSettingsResult {
|
|
||||||
name: string;
|
|
||||||
hide_create_tower_button: boolean;
|
|
||||||
keep_tasks_open: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'lt-page-settings',
|
|
||||||
standalone: true,
|
|
||||||
imports: [ReactiveFormsModule, ToggleComponent],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
template: `
|
|
||||||
<div class="header">
|
|
||||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
|
||||||
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
|
||||||
<input
|
|
||||||
id="ps-name"
|
|
||||||
type="text"
|
|
||||||
formControlName="name"
|
|
||||||
maxlength="200"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Page name…"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<lt-toggle
|
|
||||||
[checked]="hideCreateTowerButton()"
|
|
||||||
(checkedChange)="hideCreateTowerButton.set($event)"
|
|
||||||
offLabel="Show add-tower button"
|
|
||||||
onLabel="Hide add-tower button"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<lt-toggle
|
|
||||||
[checked]="keepTasksOpen()"
|
|
||||||
(checkedChange)="keepTasksOpen.set($event)"
|
|
||||||
offLabel="Show tasks collapsed"
|
|
||||||
onLabel="Keep tasks open"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" [disabled]="form.invalid">
|
|
||||||
{{ page() ? 'Save' : 'Create page' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (page()) {
|
|
||||||
<button type="button" (click)="delete.emit()">Delete page</button>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
@import '../../../library/main';
|
|
||||||
|
|
||||||
:host {
|
|
||||||
@include card();
|
|
||||||
width: 66vw;
|
|
||||||
max-width: 400px;
|
|
||||||
@media (max-width: $mobile-width) {
|
|
||||||
width: 88vw;
|
|
||||||
max-width: 88vw;
|
|
||||||
padding: var(--medium-padding);
|
|
||||||
}
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: var(--large-padding);
|
|
||||||
position: relative;
|
|
||||||
box-shadow: $shadow;
|
|
||||||
@include inner-spacing(var(--large-padding));
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
@include center-child();
|
|
||||||
|
|
||||||
.exit {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--large-padding);
|
|
||||||
@include exit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='text'] {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: block;
|
|
||||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
|
||||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
|
||||||
// tap target (plain block centres the text and strands the underline).
|
|
||||||
@media (max-width: $mobile-width) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class PageSettingsComponent implements OnInit {
|
|
||||||
readonly page = input<Page | null>(null);
|
|
||||||
readonly save = output<PageSettingsResult>();
|
|
||||||
readonly delete = output<void>();
|
|
||||||
readonly close = output<void>();
|
|
||||||
|
|
||||||
private readonly fb = inject(FormBuilder);
|
|
||||||
|
|
||||||
form = this.fb.group({
|
|
||||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
|
||||||
});
|
|
||||||
|
|
||||||
hideCreateTowerButton = signal(false);
|
|
||||||
readonly keepTasksOpen = signal(false);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const p = this.page();
|
|
||||||
if (p) {
|
|
||||||
this.form.patchValue({ name: p.name });
|
|
||||||
this.hideCreateTowerButton.set(p.hide_create_tower_button);
|
|
||||||
this.keepTasksOpen.set(p.keep_tasks_open);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(): void {
|
|
||||||
if (this.form.invalid) return;
|
|
||||||
const v = this.form.value;
|
|
||||||
this.save.emit({
|
|
||||||
name: v.name ?? '',
|
|
||||||
hide_create_tower_button: this.hideCreateTowerButton(),
|
|
||||||
keep_tasks_open: this.keepTasksOpen(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,7 +36,7 @@ const UUIDV4_RE =
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[value]="pageName()"
|
[value]="pageName()"
|
||||||
(blur)="onRenamePage($any($event.target).value)"
|
(blur)="onRenamePage($any($event.target))"
|
||||||
placeholder="Page name…"
|
placeholder="Page name…"
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -271,10 +271,14 @@ export class SettingsComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onRenamePage(value: string): void {
|
onRenamePage(input: HTMLInputElement): void {
|
||||||
const trimmed = value.trim();
|
const trimmed = input.value.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) {
|
||||||
|
input.value = this.pageName();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.pageName.set(trimmed);
|
this.pageName.set(trimmed);
|
||||||
|
input.value = trimmed;
|
||||||
this.flushPageUpdate();
|
this.flushPageUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export interface TowerSettingsResult {
|
||||||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,16 @@
|
||||||
}
|
}
|
||||||
@if (!page().hide_create_tower_button) {
|
@if (!page().hide_create_tower_button) {
|
||||||
<div class="add-tower-wrapper">
|
<div class="add-tower-wrapper">
|
||||||
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
|
<img
|
||||||
|
class="add-tower"
|
||||||
|
src="assets/plus-sign.svg"
|
||||||
|
alt="Add tower"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="showAddTower.set(true)"
|
||||||
|
(keydown.enter)="showAddTower.set(true)"
|
||||||
|
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -57,7 +66,7 @@
|
||||||
<lt-modal (close)="cancelTowerDelete()">
|
<lt-modal (close)="cancelTowerDelete()">
|
||||||
<div class="confirm-delete">
|
<div class="confirm-delete">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
|
||||||
<h2>Delete tower</h2>
|
<h2>Delete tower</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../../styles';
|
@import '../../../library/main';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import {
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
HostListener,
|
HostListener,
|
||||||
|
effect,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Page } from '../../models';
|
import { Page } from '../../models';
|
||||||
import { StoreService } from '../../services/store.service';
|
import { StoreService } from '../../services/store.service';
|
||||||
|
|
@ -16,7 +19,6 @@ import {
|
||||||
DoubleSliderComponent,
|
DoubleSliderComponent,
|
||||||
DoubleSliderRange,
|
DoubleSliderRange,
|
||||||
} from '../shared/double-slider/double-slider.component';
|
} from '../shared/double-slider/double-slider.component';
|
||||||
import { computed } from '@angular/core';
|
|
||||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||||
import { ModalStateService } from '../../services/modal-state.service';
|
import { ModalStateService } from '../../services/modal-state.service';
|
||||||
|
|
||||||
|
|
@ -116,6 +118,14 @@ export class PageComponent {
|
||||||
/** Selected date range — `null` = show everything. */
|
/** Selected date range — `null` = show everything. */
|
||||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (!this.showSlider()) {
|
||||||
|
untracked(() => this.dateRange.set(null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +157,7 @@ export class PageComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeleteTower(towerId: string): void {
|
onDeleteTower(towerId: string): void {
|
||||||
this.store.deleteTower(this.page().id, towerId);
|
this.confirmDeleteTowerId.set(towerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Block mutations ────────────────────────────────────────────────────────
|
// ── Block mutations ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../../styles';
|
@import '../../../library/main';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,6 @@ export class PagesComponent implements OnDestroy {
|
||||||
return pages[0] ?? null;
|
return pages[0] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
|
|
||||||
|
|
||||||
readonly confirmDeletePageName = computed(() => {
|
readonly confirmDeletePageName = computed(() => {
|
||||||
const id = this.confirmDeletePageId();
|
const id = this.confirmDeletePageId();
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
|
||||||
id="ds-1"
|
id="ds-1"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
[max]="MAX - 1"
|
[max]="maxIndex()"
|
||||||
[value]="oneValue()"
|
[value]="oneValue()"
|
||||||
(input)="oneValue.set(+$any($event.target).value)"
|
(input)="oneValue.set(+$any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
|
||||||
id="ds-2"
|
id="ds-2"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
[max]="MAX - 1"
|
[max]="maxIndex()"
|
||||||
[value]="otherValue()"
|
[value]="otherValue()"
|
||||||
(input)="otherValue.set(+$any($event.target).value)"
|
(input)="otherValue.set(+$any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -168,10 +168,9 @@ export class DoubleSliderComponent {
|
||||||
|
|
||||||
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
||||||
|
|
||||||
readonly MAX = 100;
|
|
||||||
|
|
||||||
readonly oneValue = signal(0);
|
readonly oneValue = signal(0);
|
||||||
readonly otherValue = signal(this.MAX - 1);
|
readonly otherValue = signal(0);
|
||||||
|
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
|
||||||
|
|
||||||
private prevValuesLength = 0;
|
private prevValuesLength = 0;
|
||||||
|
|
||||||
|
|
@ -198,32 +197,33 @@ export class DoubleSliderComponent {
|
||||||
const hi = Math.max(a, b);
|
const hi = Math.max(a, b);
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
this.rangeChange.emit({
|
this.rangeChange.emit({
|
||||||
from: vs[this.indexFromValue(lo)],
|
from: vs[this.clampIndex(lo)],
|
||||||
to: vs[this.indexFromValue(hi)],
|
to: vs[this.clampIndex(hi)],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Snap the higher thumb to MAX - 1 when a new entry is appended.
|
// Snap the higher thumb to the newest value when a new entry is appended.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const len = this.values().length;
|
const len = this.values().length;
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
|
const max = Math.max(0, len - 1);
|
||||||
if (len > this.prevValuesLength) {
|
if (len > this.prevValuesLength) {
|
||||||
const a = this.oneValue();
|
const a = this.oneValue();
|
||||||
const b = this.otherValue();
|
const b = this.otherValue();
|
||||||
if (a > b) this.oneValue.set(this.MAX - 1);
|
if (a > b) this.oneValue.set(max);
|
||||||
else this.otherValue.set(this.MAX - 1);
|
else this.otherValue.set(max);
|
||||||
|
} else {
|
||||||
|
if (this.oneValue() > max) this.oneValue.set(max);
|
||||||
|
if (this.otherValue() > max) this.otherValue.set(max);
|
||||||
}
|
}
|
||||||
this.prevValuesLength = len;
|
this.prevValuesLength = len;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private indexFromValue(value: number): number {
|
private clampIndex(value: number): number {
|
||||||
return Math.min(
|
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
|
||||||
this.values().length - 1,
|
|
||||||
Math.floor((value / this.MAX) * this.values().length),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,9 +231,10 @@ export class DoubleSliderComponent {
|
||||||
* upward and rotates -45° as a thumb approaches.
|
* upward and rotates -45° as a thumb approaches.
|
||||||
*/
|
*/
|
||||||
getOffset(index: number): string {
|
getOffset(index: number): string {
|
||||||
const labelIndex = index / Math.max(1, this.drawnLabels().length);
|
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||||
const a = this.oneValue() / this.MAX - 0.1;
|
const max = Math.max(1, this.maxIndex());
|
||||||
const b = this.otherValue() / this.MAX - 0.1;
|
const a = this.oneValue() / max - 0.1;
|
||||||
|
const b = this.otherValue() / max - 0.1;
|
||||||
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
||||||
const ACTIVE_ZONE = 0.2;
|
const ACTIVE_ZONE = 0.2;
|
||||||
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import {
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
OnChanges,
|
|
||||||
SimpleChanges,
|
|
||||||
ElementRef,
|
ElementRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
|
|
@ -22,7 +20,15 @@ import {
|
||||||
[class.always-shadow]="alwaysDropShadow()"
|
[class.always-shadow]="alwaysDropShadow()"
|
||||||
>
|
>
|
||||||
<div class="background" [class.active]="open()"></div>
|
<div class="background" [class.active]="open()"></div>
|
||||||
<div class="top" (click)="toggleOpen($event)">
|
<div
|
||||||
|
class="top"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
[attr.aria-expanded]="open()"
|
||||||
|
(click)="toggleOpen($event)"
|
||||||
|
(keydown.enter)="toggleOpen($event)"
|
||||||
|
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
|
||||||
|
>
|
||||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,6 +39,7 @@ import {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[value]="item"
|
[value]="item"
|
||||||
|
maxlength="200"
|
||||||
(blur)="onRename(item, $any($event.target).value)"
|
(blur)="onRename(item, $any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
|
|
@ -46,6 +53,7 @@ import {
|
||||||
type="text"
|
type="text"
|
||||||
#addInput
|
#addInput
|
||||||
placeholder="Add a value…"
|
placeholder="Add a value…"
|
||||||
|
maxlength="200"
|
||||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
@ -365,7 +373,7 @@ import {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class SelectAddComponent implements OnChanges {
|
export class SelectAddComponent {
|
||||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||||
/** List of string options */
|
/** List of string options */
|
||||||
readonly items = input<string[]>([]);
|
readonly items = input<string[]>([]);
|
||||||
|
|
@ -415,10 +423,6 @@ export class SelectAddComponent implements OnChanges {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(_changes: SimpleChanges): void {
|
|
||||||
// Nothing to do — signals handle reactivity
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onDocumentClick(event: MouseEvent): void {
|
onDocumentClick(event: MouseEvent): void {
|
||||||
if (!this.open()) return;
|
if (!this.open()) return;
|
||||||
|
|
@ -428,7 +432,7 @@ export class SelectAddComponent implements OnChanges {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOpen(event: MouseEvent): void {
|
toggleOpen(event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.open.update((v) => !v);
|
this.open.update((v) => !v);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,14 @@ import {
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="toggle">
|
<div class="toggle">
|
||||||
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
|
<span
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
[class.active]="!checked()"
|
||||||
|
(click)="set(false)"
|
||||||
|
(keydown.enter)="set(false)"
|
||||||
|
(keydown.space)="$event.preventDefault(); set(false)"
|
||||||
|
>{{ offLabel() }}</span>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -20,7 +27,14 @@ import {
|
||||||
(change)="set(!checked())"
|
(change)="set(!checked())"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
|
<span
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
[class.active]="checked()"
|
||||||
|
(click)="set(true)"
|
||||||
|
(keydown.enter)="set(true)"
|
||||||
|
(keydown.space)="$event.preventDefault(); set(true)"
|
||||||
|
>{{ onLabel() }}</span>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,20 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
(pointerdown)="$event.stopPropagation()"
|
(pointerdown)="$event.stopPropagation()"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
(touchstart)="$event.stopPropagation()"
|
(touchstart)="$event.stopPropagation()"
|
||||||
(pointerup)="toggleExpanded($event)"
|
|
||||||
(touchend)="toggleExpanded($event)"
|
|
||||||
(click)="toggleExpanded($event)"
|
|
||||||
>
|
>
|
||||||
<p
|
<button
|
||||||
|
type="button"
|
||||||
class="header"
|
class="header"
|
||||||
(pointerup)="toggleExpanded($event)"
|
|
||||||
(touchend)="toggleExpanded($event)"
|
|
||||||
(click)="toggleExpanded($event)"
|
(click)="toggleExpanded($event)"
|
||||||
|
[attr.aria-expanded]="expanded()"
|
||||||
>
|
>
|
||||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
@if (pending().length === 0) {
|
||||||
</p>
|
<span aria-hidden="true"> </span>
|
||||||
|
} @else {
|
||||||
|
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
<div
|
<div
|
||||||
#all
|
#all
|
||||||
class="all-task"
|
class="all-task"
|
||||||
|
|
@ -49,12 +50,12 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
(click)="$event.stopPropagation(); markDone.emit(b)"
|
(click)="$event.stopPropagation(); markDone.emit(b)"
|
||||||
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
||||||
></button>
|
></button>
|
||||||
<p
|
<button
|
||||||
|
type="button"
|
||||||
|
class="task-description"
|
||||||
[style.color]="colorOf(b.tag)"
|
[style.color]="colorOf(b.tag)"
|
||||||
(pointerup)="$event.stopPropagation()"
|
|
||||||
(touchend)="$event.stopPropagation()"
|
|
||||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||||
>{{ b.description || b.tag }}</p>
|
>{{ b.description || b.tag }}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,9 +88,19 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
.header { cursor: pointer; }
|
.header {
|
||||||
|
all: unset;
|
||||||
|
@include medium-text();
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
p { font-size: var(--medium-font-size); }
|
&::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.all-task {
|
.all-task {
|
||||||
@include inner-spacing(var(--small-padding));
|
@include inner-spacing(var(--small-padding));
|
||||||
|
|
@ -124,7 +135,7 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
gap: calc(var(--small-padding) / 2);
|
gap: calc(var(--small-padding) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover p {
|
&:hover .task-description {
|
||||||
@media (min-width: $mobile-width) {
|
@media (min-width: $mobile-width) {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +198,9 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.task-description {
|
||||||
|
all: unset;
|
||||||
|
@include medium-text();
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
@ -203,6 +216,7 @@ import { getColorOfTag } from '../../utils/color';
|
||||||
}
|
}
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
&::after { content: none; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +236,6 @@ export class TasksComponent {
|
||||||
readonly edit = output<Block>();
|
readonly edit = output<Block>();
|
||||||
|
|
||||||
readonly expanded = signal(false);
|
readonly expanded = signal(false);
|
||||||
private lastToggleAt = 0;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
||||||
|
|
@ -241,12 +254,6 @@ export class TasksComponent {
|
||||||
|
|
||||||
toggleExpanded(event: Event): void {
|
toggleExpanded(event: Event): void {
|
||||||
event.stopPropagation();
|
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);
|
this.expanded.update((v) => !v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,6 @@ export function selectVisibleStyledBlocks(
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="container"
|
class="container"
|
||||||
[class.trash-highlight]="trashHighlight()"
|
|
||||||
>
|
>
|
||||||
<lt-tasks
|
<lt-tasks
|
||||||
[pending]="pending()"
|
[pending]="pending()"
|
||||||
|
|
@ -264,7 +263,7 @@ export function selectVisibleStyledBlocks(
|
||||||
transform: scale(0.75);
|
transform: scale(0.75);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +313,7 @@ export function selectVisibleStyledBlocks(
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
:before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -513,8 +512,6 @@ export function selectVisibleStyledBlocks(
|
||||||
export class TowerComponent implements AfterViewInit, OnDestroy {
|
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
// ── Inputs ─────────────────────────────────────────────────────────────────
|
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||||
readonly tower = input.required<Tower>();
|
readonly tower = input.required<Tower>();
|
||||||
/** Set by page component when this tower is being dragged over trash. */
|
|
||||||
readonly trashHighlight = input<boolean>(false);
|
|
||||||
/** Optional date range filter — when set, blocks with `created_at`
|
/** Optional date range filter — when set, blocks with `created_at`
|
||||||
* outside [from, to] are hidden from the falling stack. */
|
* outside [from, to] are hidden from the falling stack. */
|
||||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||||
|
|
@ -547,6 +544,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private destroyed = false;
|
||||||
|
private readonly animationFrames = new Set<number>();
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||||
|
|
@ -638,6 +637,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
for (const id of this.animationFrames) cancelAnimationFrame(id);
|
||||||
|
this.animationFrames.clear();
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,25 +790,48 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
block._opacity = '0';
|
block._opacity = '0';
|
||||||
}
|
}
|
||||||
this._visibleBlocks.set(visibleStyled);
|
this._visibleBlocks.set(visibleStyled);
|
||||||
requestAnimationFrame(() => {
|
this.requestFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
this.requestFrame(() => {
|
||||||
for (const block of blocks) {
|
if (this.destroyed) return;
|
||||||
block._anim = 'descend';
|
this.finishDescendAnimation(blocks);
|
||||||
block._transform = 'translateY(0)';
|
|
||||||
block._opacity = '1';
|
|
||||||
}
|
|
||||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requestFrame(callback: () => void): void {
|
||||||
|
if (typeof requestAnimationFrame !== 'function') {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
this.animationFrames.delete(id);
|
||||||
|
if (!this.destroyed) callback();
|
||||||
|
});
|
||||||
|
this.animationFrames.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishDescendAnimation(blocks: StyledBlock[]): void {
|
||||||
|
for (const block of blocks) {
|
||||||
|
block._anim = 'descend';
|
||||||
|
block._transform = 'translateY(0)';
|
||||||
|
block._opacity = '1';
|
||||||
|
}
|
||||||
|
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onRename(event: Event): void {
|
onRename(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const newName = input.value.trim();
|
const newName = input.value.trim();
|
||||||
if (newName && newName !== this.tower().name) {
|
if (!newName) {
|
||||||
|
input.value = this.tower().name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName !== this.tower().name) {
|
||||||
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||||
|
} else {
|
||||||
|
input.value = this.tower().name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export type SaveStatus =
|
||||||
| 'saving'
|
| 'saving'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'retrying'
|
| 'retrying'
|
||||||
| 'error' // generic / network — will keep trying
|
| 'error' // generic / network — retries exhausted until the next mutation
|
||||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
| 'too-large' // 413 — payload exceeds the server cap, will not retry
|
||||||
| 'rate-limited' // 429 — will retry after Retry-After
|
| 'rate-limited' // 429 — will retry after Retry-After
|
||||||
| 'invalid'; // 400 — server rejected the body, won't retry
|
| 'invalid'; // 400 — server rejected the body, will not retry
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ export class ApiService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
putData(token: string, tree: TreeDto): Promise<void> {
|
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||||
return firstValueFrom(
|
await firstValueFrom(
|
||||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,61 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import type { TreeDto } from '../models';
|
||||||
|
|
||||||
// Mock environment
|
describe('ApiService', () => {
|
||||||
vi.mock('../../environments/environment', () => ({
|
let service: ApiService;
|
||||||
environment: { apiBase: 'http://test-api', production: false },
|
let http: HttpTestingController;
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ApiService URL patterns', () => {
|
beforeEach(() => {
|
||||||
const baseUrl = 'http://test-api';
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), ApiService],
|
||||||
it('constructs correct health URL', () => {
|
});
|
||||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
service = TestBed.inject(ApiService);
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('constructs correct register URL', () => {
|
afterEach(() => {
|
||||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
http.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('constructs correct data URL', () => {
|
it('gets health', async () => {
|
||||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
const promise = service.health();
|
||||||
|
const req = http.expectOne('/api/v1/health');
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
await expect(promise).resolves.toEqual({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats Authorization header correctly', () => {
|
it('registers a token', async () => {
|
||||||
const token = 'abc-def-123';
|
const promise = service.register('token-1');
|
||||||
const header = `Bearer ${token}`;
|
const req = http.expectOne('/api/v1/register');
|
||||||
expect(header).toBe('Bearer abc-def-123');
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({ token: 'token-1' });
|
||||||
|
req.flush({ user_id: 'token-1' });
|
||||||
|
await expect(promise).resolves.toEqual({ user_id: 'token-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets data with a bearer token', async () => {
|
||||||
|
const tree: TreeDto = { pages: [] };
|
||||||
|
const promise = service.getData('token-1');
|
||||||
|
const req = http.expectOne('/api/v1/data');
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||||
|
req.flush(tree);
|
||||||
|
await expect(promise).resolves.toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts data with a bearer token', async () => {
|
||||||
|
const tree: TreeDto = { pages: [] };
|
||||||
|
const promise = service.putData('token-1', tree);
|
||||||
|
const req = http.expectOne('/api/v1/data');
|
||||||
|
expect(req.request.method).toBe('PUT');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||||
|
expect(req.request.body).toBe(tree);
|
||||||
|
req.flush(null);
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Injectable, computed, signal } from '@angular/core';
|
||||||
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||||
*
|
*
|
||||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
* Used by `page.component` to disable tower drag-and-drop while any modal
|
||||||
* (block-edit carousel, page-settings, tower-settings, confirm-delete,
|
* (block-edit carousel, settings, tower-settings, confirm-delete,
|
||||||
* settings) is on screen — otherwise the user can drag towers from behind
|
* settings) is on screen — otherwise the user can drag towers from behind
|
||||||
* the open card.
|
* the open card.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
|
||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||||
|
|
||||||
const TOKEN_KEY = 'life-towers.token.v4';
|
const TOKEN_KEY = 'life-towers.token.v4';
|
||||||
const CACHE_KEY = 'life-towers.cache.v4';
|
const CACHE_KEY_PREFIX = 'life-towers.cache.v4';
|
||||||
const DEBOUNCE_MS = 750;
|
const DEBOUNCE_MS = 750;
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ function uuidV4(): string {
|
||||||
|
|
||||||
function isUuidV4(value: string): boolean {
|
function isUuidV4(value: string): boolean {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||||
value,
|
value.toLowerCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +51,10 @@ function safeSet(key: string, value: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cacheKeyForToken(token: string): string {
|
||||||
|
return `${CACHE_KEY_PREFIX}.${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingPut {
|
interface PendingPut {
|
||||||
token: string;
|
token: string;
|
||||||
tree: TreeDto;
|
tree: TreeDto;
|
||||||
|
|
@ -87,11 +91,10 @@ export class StoreService implements OnDestroy {
|
||||||
readonly loading = this._loading.asReadonly();
|
readonly loading = this._loading.asReadonly();
|
||||||
readonly token = this._token.asReadonly();
|
readonly token = this._token.asReadonly();
|
||||||
|
|
||||||
readonly pageCount = computed(() => this._pages().length);
|
|
||||||
|
|
||||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private retryResolver: (() => void) | null = null;
|
||||||
private flushInFlight = false;
|
private flushInFlight = false;
|
||||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||||
private dirtyDuringFlush = false;
|
private dirtyDuringFlush = false;
|
||||||
|
|
@ -99,14 +102,8 @@ export class StoreService implements OnDestroy {
|
||||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||||
private readonly storageListener = (e: StorageEvent) => {
|
private readonly storageListener = (e: StorageEvent) => {
|
||||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||||
// Another tab switched accounts. Re-init with the new token instead of
|
this.switchToken(e.newValue);
|
||||||
// continuing to sync the old account's state to the new one.
|
} else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) {
|
||||||
this._token.set(e.newValue);
|
|
||||||
this._pages.set([]);
|
|
||||||
this._loading.set(true);
|
|
||||||
this.cancelPendingWrites();
|
|
||||||
void this.init();
|
|
||||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
|
||||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
||||||
// (to avoid clobbering our own state with the other tab's older view).
|
// (to avoid clobbering our own state with the other tab's older view).
|
||||||
try {
|
try {
|
||||||
|
|
@ -120,17 +117,21 @@ export class StoreService implements OnDestroy {
|
||||||
|
|
||||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private initGeneration = 0;
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (this.initPromise) return this.initPromise;
|
if (this.initPromise) return this.initPromise;
|
||||||
this.initPromise = this.doInit().finally(() => {
|
const generation = ++this.initGeneration;
|
||||||
this.initPromise = null;
|
this.initPromise = this.doInit(generation).finally(() => {
|
||||||
|
if (this.initGeneration === generation) {
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doInit(): Promise<void> {
|
private async doInit(generation: number): Promise<void> {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// (idempotent — adding the same listener twice is a no-op)
|
// (idempotent — adding the same listener twice is a no-op)
|
||||||
window.addEventListener('storage', this.storageListener);
|
window.addEventListener('storage', this.storageListener);
|
||||||
|
|
@ -140,6 +141,9 @@ export class StoreService implements OnDestroy {
|
||||||
if (stored && !isUuidV4(stored)) {
|
if (stored && !isUuidV4(stored)) {
|
||||||
// Garbage in localStorage from a buggy past version — refuse it.
|
// Garbage in localStorage from a buggy past version — refuse it.
|
||||||
stored = null;
|
stored = null;
|
||||||
|
} else if (stored) {
|
||||||
|
stored = stored.toLowerCase();
|
||||||
|
safeSet(TOKEN_KEY, stored);
|
||||||
}
|
}
|
||||||
const token = stored ?? uuidV4();
|
const token = stored ?? uuidV4();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
|
|
@ -157,7 +161,7 @@ export class StoreService implements OnDestroy {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tree = await this.api.getData(token);
|
const tree = await this.api.getData(token);
|
||||||
this.adoptServerTree(tree);
|
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { status?: number })?.status;
|
const status = (err as { status?: number })?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
|
|
@ -165,26 +169,32 @@ export class StoreService implements OnDestroy {
|
||||||
try {
|
try {
|
||||||
await this.api.register(token);
|
await this.api.register(token);
|
||||||
const tree = await this.api.getData(token);
|
const tree = await this.api.getData(token);
|
||||||
this.adoptServerTree(tree);
|
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||||
} catch {
|
} catch {
|
||||||
this.loadFromCache();
|
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.loadFromCache();
|
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._loading.set(false);
|
if (this.initGeneration === generation) {
|
||||||
|
this._loading.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCurrentInit(generation: number, token: string): boolean {
|
||||||
|
return this.initGeneration === generation && this._token() === token;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a freshly-fetched server tree. If the server is empty but our local
|
* Apply a freshly-fetched server tree. If the server is empty but our local
|
||||||
* cache holds data, the cache wins and we schedule a push — otherwise the
|
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||||
* "server forgot me" recovery would silently wipe offline edits.
|
* "server forgot me" recovery would silently wipe offline edits.
|
||||||
*/
|
*/
|
||||||
private adoptServerTree(tree: TreeDto): void {
|
private adoptServerTree(tree: TreeDto, token: string): void {
|
||||||
if (tree.pages.length === 0) {
|
if (tree.pages.length === 0) {
|
||||||
const cached = safeGet(CACHE_KEY);
|
const cached = safeGet(cacheKeyForToken(token));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
const cachedTree: TreeDto = JSON.parse(cached);
|
const cachedTree: TreeDto = JSON.parse(cached);
|
||||||
|
|
@ -199,11 +209,11 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._pages.set(tree.pages);
|
this._pages.set(tree.pages);
|
||||||
this.updateCache(tree);
|
this.updateCache(token, tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadFromCache(): void {
|
private loadFromCache(token: string): void {
|
||||||
const raw = safeGet(CACHE_KEY);
|
const raw = safeGet(cacheKeyForToken(token));
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
const tree: TreeDto = JSON.parse(raw);
|
const tree: TreeDto = JSON.parse(raw);
|
||||||
|
|
@ -214,8 +224,8 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCache(tree: TreeDto): void {
|
private updateCache(token: string, tree: TreeDto): void {
|
||||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
safeSet(cacheKeyForToken(token), JSON.stringify(tree));
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelPendingWrites(): void {
|
private cancelPendingWrites(): void {
|
||||||
|
|
@ -227,6 +237,11 @@ export class StoreService implements OnDestroy {
|
||||||
clearTimeout(this.retryTimer);
|
clearTimeout(this.retryTimer);
|
||||||
this.retryTimer = null;
|
this.retryTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.retryResolver !== null) {
|
||||||
|
const resolve = this.retryResolver;
|
||||||
|
this.retryResolver = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
this.dirtyDuringFlush = false;
|
this.dirtyDuringFlush = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,13 +276,13 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderPages(fromIndex: number, toIndex: number): void {
|
reorderPages(fromIndex: number, toIndex: number): void {
|
||||||
|
let changed = false;
|
||||||
this._pages.update((pages) => {
|
this._pages.update((pages) => {
|
||||||
const arr = [...pages];
|
const reordered = reorder(pages, fromIndex, toIndex);
|
||||||
const [item] = arr.splice(fromIndex, 1);
|
changed = reordered !== null;
|
||||||
arr.splice(toIndex, 0, item);
|
return reordered ?? pages;
|
||||||
return arr;
|
|
||||||
});
|
});
|
||||||
this.scheduleSave();
|
if (changed) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||||
|
|
@ -301,16 +316,17 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||||
|
let changed = false;
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) => {
|
pages.map((p) => {
|
||||||
if (p.id !== pageId) return p;
|
if (p.id !== pageId) return p;
|
||||||
const towers = [...p.towers];
|
const towers = reorder(p.towers, fromIndex, toIndex);
|
||||||
const [item] = towers.splice(fromIndex, 1);
|
if (towers === null) return p;
|
||||||
towers.splice(toIndex, 0, item);
|
changed = true;
|
||||||
return { ...p, towers };
|
return { ...p, towers };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.scheduleSave();
|
if (changed) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
|
|
@ -352,6 +368,7 @@ export class StoreService implements OnDestroy {
|
||||||
blockId: string,
|
blockId: string,
|
||||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||||
): void {
|
): void {
|
||||||
|
let becameDone = false;
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) =>
|
pages.map((p) =>
|
||||||
p.id === pageId
|
p.id === pageId
|
||||||
|
|
@ -359,16 +376,21 @@ export class StoreService implements OnDestroy {
|
||||||
...p,
|
...p,
|
||||||
towers: p.towers.map((t) =>
|
towers: p.towers.map((t) =>
|
||||||
t.id === towerId
|
t.id === towerId
|
||||||
? {
|
? (() => {
|
||||||
...t,
|
const result = this.patchBlockList(t.blocks, blockId, patch);
|
||||||
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
|
becameDone = result.becameDone;
|
||||||
}
|
return { ...t, blocks: result.blocks };
|
||||||
|
})()
|
||||||
: t,
|
: t,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (becameDone) {
|
||||||
|
this.analytics.trackStart();
|
||||||
|
this.analytics.trackBlockCompleted();
|
||||||
|
}
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,35 +412,6 @@ export class StoreService implements OnDestroy {
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
|
||||||
let becameDone = false;
|
|
||||||
this._pages.update((pages) =>
|
|
||||||
pages.map((p) =>
|
|
||||||
p.id === pageId
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
towers: p.towers.map((t) =>
|
|
||||||
t.id === towerId
|
|
||||||
? (() => {
|
|
||||||
const block = t.blocks.find((b) => b.id === blockId);
|
|
||||||
if (!block) return t;
|
|
||||||
const result = this.patchBlockList(t.blocks, blockId, {
|
|
||||||
is_done: !block.is_done,
|
|
||||||
});
|
|
||||||
becameDone = result.becameDone;
|
|
||||||
return { ...t, blocks: result.blocks };
|
|
||||||
})()
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: p,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.analytics.trackStart();
|
|
||||||
if (becameDone) this.analytics.trackBlockCompleted();
|
|
||||||
this.scheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private patchBlockList(
|
private patchBlockList(
|
||||||
blocks: Block[],
|
blocks: Block[],
|
||||||
blockId: string,
|
blockId: string,
|
||||||
|
|
@ -458,10 +451,13 @@ export class StoreService implements OnDestroy {
|
||||||
* if the timing flipped).
|
* if the timing flipped).
|
||||||
*/
|
*/
|
||||||
switchToken(newToken: string): void {
|
switchToken(newToken: string): void {
|
||||||
if (!isUuidV4(newToken)) return;
|
const token = newToken.toLowerCase();
|
||||||
|
if (!isUuidV4(token)) return;
|
||||||
this.cancelPendingWrites();
|
this.cancelPendingWrites();
|
||||||
safeSet(TOKEN_KEY, newToken);
|
this.initGeneration++;
|
||||||
this._token.set(newToken);
|
this.initPromise = null;
|
||||||
|
safeSet(TOKEN_KEY, token);
|
||||||
|
this._token.set(token);
|
||||||
this._pages.set([]);
|
this._pages.set([]);
|
||||||
this._loading.set(true);
|
this._loading.set(true);
|
||||||
this._saveStatus.set('idle');
|
this._saveStatus.set('idle');
|
||||||
|
|
@ -522,7 +518,7 @@ export class StoreService implements OnDestroy {
|
||||||
try {
|
try {
|
||||||
await this.api.putData(put.token, put.tree);
|
await this.api.putData(put.token, put.tree);
|
||||||
this._saveStatus.set('saved');
|
this._saveStatus.set('saved');
|
||||||
this.updateCache(put.tree);
|
this.updateCache(put.token, put.tree);
|
||||||
return;
|
return;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { status?: number })?.status;
|
const status = (err as { status?: number })?.status;
|
||||||
|
|
@ -566,8 +562,10 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
this.retryResolver = resolve;
|
||||||
this.retryTimer = setTimeout(() => {
|
this.retryTimer = setTimeout(() => {
|
||||||
this.retryTimer = null;
|
this.retryTimer = null;
|
||||||
|
this.retryResolver = null;
|
||||||
resolve();
|
resolve();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
});
|
});
|
||||||
|
|
@ -710,3 +708,22 @@ export class StoreService implements OnDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reorder<T>(items: readonly T[], fromIndex: number, toIndex: number): T[] | null {
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
!Number.isInteger(fromIndex) ||
|
||||||
|
!Number.isInteger(toIndex) ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= items.length ||
|
||||||
|
toIndex >= items.length
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...items];
|
||||||
|
const [item] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
import type { TreeDto } from '../models';
|
import type { TreeDto } from '../models';
|
||||||
|
|
||||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||||
|
|
@ -63,7 +64,9 @@ function makeMockApi(): MockApi {
|
||||||
|
|
||||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||||
const TOKEN_KEY = 'life-towers.token.v4';
|
const TOKEN_KEY = 'life-towers.token.v4';
|
||||||
const CACHE_KEY = 'life-towers.cache.v4';
|
const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`;
|
||||||
|
const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||||
|
const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function configure(api: MockApi): StoreService {
|
function configure(api: MockApi): StoreService {
|
||||||
|
|
@ -72,6 +75,18 @@ function configure(api: MockApi): StoreService {
|
||||||
providers: [
|
providers: [
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
{ provide: ApiService, useValue: api },
|
{ provide: ApiService, useValue: api },
|
||||||
|
{
|
||||||
|
provide: AnalyticsService,
|
||||||
|
useValue: {
|
||||||
|
init: vi.fn(),
|
||||||
|
trackStart: vi.fn(),
|
||||||
|
trackExampleLoaded: vi.fn(),
|
||||||
|
trackPageCreated: vi.fn(),
|
||||||
|
trackTowerCreated: vi.fn(),
|
||||||
|
trackBlockCreated: vi.fn(),
|
||||||
|
trackBlockCompleted: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
StoreService,
|
StoreService,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -137,6 +152,18 @@ describe('StoreService', () => {
|
||||||
expect(store.token()).toBe(FIXED_UUID);
|
expect(store.token()).toBe(FIXED_UUID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('canonicalizes a stored uppercase token', async () => {
|
||||||
|
storage[TOKEN_KEY] = FIXED_UUID.toUpperCase();
|
||||||
|
const api = makeMockApi();
|
||||||
|
const store = configure(api);
|
||||||
|
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||||
|
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||||
|
expect(store.token()).toBe(FIXED_UUID);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||||
const api = makeMockApi();
|
const api = makeMockApi();
|
||||||
|
|
@ -292,13 +319,17 @@ describe('StoreService', () => {
|
||||||
expect(page.towers).toHaveLength(3);
|
expect(page.towers).toHaveLength(3);
|
||||||
|
|
||||||
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
|
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
|
||||||
expect(doneBlocks.length).toBeGreaterThanOrEqual(90);
|
const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0);
|
||||||
|
expect(doneSquares).toBeGreaterThanOrEqual(90);
|
||||||
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
|
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
|
||||||
.toBeGreaterThan(1);
|
.toBeGreaterThan(1);
|
||||||
|
|
||||||
for (const tower of page.towers) {
|
for (const tower of page.towers) {
|
||||||
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
|
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
|
||||||
expect(doneDates.length).toBeGreaterThanOrEqual(30);
|
const doneSquareCount = tower.blocks
|
||||||
|
.filter((b) => b.is_done)
|
||||||
|
.reduce((sum, block) => sum + block.difficulty, 0);
|
||||||
|
expect(doneSquareCount).toBeGreaterThanOrEqual(30);
|
||||||
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
|
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
|
||||||
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
|
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
|
||||||
}
|
}
|
||||||
|
|
@ -438,15 +469,73 @@ describe('StoreService', () => {
|
||||||
|
|
||||||
// Mutate, then switch BEFORE the debounce fires.
|
// Mutate, then switch BEFORE the debounce fires.
|
||||||
store.addPage('old-account');
|
store.addPage('old-account');
|
||||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
|
||||||
api.getData.mockResolvedValue({ pages: [] });
|
api.getData.mockResolvedValue({ pages: [] });
|
||||||
store.switchToken(newToken);
|
store.switchToken(OTHER_TOKEN);
|
||||||
|
|
||||||
// Run all timers — the OLD debounce must have been cancelled,
|
// Run all timers — the OLD debounce must have been cancelled,
|
||||||
// so no PUT should have happened.
|
// so no PUT should have happened.
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(api.putData).not.toHaveBeenCalled();
|
expect(api.putData).not.toHaveBeenCalled();
|
||||||
expect(store.token()).toBe(newToken);
|
expect(store.token()).toBe(OTHER_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToken invalidates an init already in flight', async () => {
|
||||||
|
storage[TOKEN_KEY] = FIXED_UUID;
|
||||||
|
const api = makeMockApi();
|
||||||
|
let resolveFirstGet: ((v: TreeDto) => void) | null = null;
|
||||||
|
api.getData
|
||||||
|
.mockReturnValueOnce(new Promise<TreeDto>((res) => (resolveFirstGet = res)))
|
||||||
|
.mockResolvedValueOnce({ pages: [mkPage('new-account')] });
|
||||||
|
const store = configure(api);
|
||||||
|
|
||||||
|
const firstInit = store.init();
|
||||||
|
store.switchToken(OTHER_TOKEN);
|
||||||
|
resolveFirstGet!({ pages: [mkPage('old-account')] });
|
||||||
|
await firstInit;
|
||||||
|
|
||||||
|
expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN);
|
||||||
|
expect(store.token()).toBe(OTHER_TOKEN);
|
||||||
|
expect(store.pages()[0].name).toBe('new-account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToken cancels a pending retry without wedging future saves', async () => {
|
||||||
|
storage[TOKEN_KEY] = FIXED_UUID;
|
||||||
|
const api = makeMockApi();
|
||||||
|
api.putData
|
||||||
|
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' }))
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const store = configure(api);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
store.addPage('old-account');
|
||||||
|
await vi.advanceTimersByTimeAsync(750);
|
||||||
|
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
api.getData.mockResolvedValue({ pages: [] });
|
||||||
|
store.switchToken(OTHER_TOKEN);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
store.addPage('new-account');
|
||||||
|
await vi.advanceTimersByTimeAsync(750);
|
||||||
|
|
||||||
|
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN);
|
||||||
|
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not load another account cache after switching tokens', async () => {
|
||||||
|
storage[TOKEN_KEY] = FIXED_UUID;
|
||||||
|
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto);
|
||||||
|
const api = makeMockApi();
|
||||||
|
const store = configure(api);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
api.getData.mockRejectedValue(httpError(0));
|
||||||
|
store.switchToken(OTHER_TOKEN);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(storage[OTHER_CACHE_KEY]).toBeUndefined();
|
||||||
|
expect(store.pages()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||||
|
|
@ -458,6 +547,19 @@ describe('StoreService', () => {
|
||||||
expect(store.token()).toBe(''); // never initialized
|
expect(store.token()).toBe(''); // never initialized
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('switchToken canonicalizes uppercase UUID input', async () => {
|
||||||
|
storage[TOKEN_KEY] = FIXED_UUID;
|
||||||
|
const api = makeMockApi();
|
||||||
|
const store = configure(api);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
store.switchToken(OTHER_TOKEN.toUpperCase());
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(store.token()).toBe(OTHER_TOKEN);
|
||||||
|
expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='text'],
|
|
||||||
input[type='email'],
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
resize: vertical;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--checkbox {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
transition: background 0.15s;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--accent-dark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--secondary {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
color: var(--text);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--danger {
|
|
||||||
background: transparent;
|
|
||||||
color: #e53e3e;
|
|
||||||
border: 1px solid #e53e3e;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(229, 62, 62, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 292.362 292.362" style="enable-background:new 0 0 292.362 292.362;" xml:space="preserve" class=""><g><g>
|
|
||||||
<path d="M286.935,69.377c-3.614-3.617-7.898-5.424-12.848-5.424H18.274c-4.952,0-9.233,1.807-12.85,5.424 C1.807,72.998,0,77.279,0,82.228c0,4.948,1.807,9.229,5.424,12.847l127.907,127.907c3.621,3.617,7.902,5.428,12.85,5.428 s9.233-1.811,12.847-5.428L286.935,95.074c3.613-3.617,5.427-7.898,5.427-12.847C292.362,77.279,290.548,72.998,286.935,69.377z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
</g></g> </svg>
|
|
||||||
|
Before Width: | Height: | Size: 746 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 528.899 528.899" style="enable-background:new 0 0 528.899 528.899;" xml:space="preserve"><g><g>
|
|
||||||
<path d="M328.883,89.125l107.59,107.589l-272.34,272.34L56.604,361.465L328.883,89.125z M518.113,63.177l-47.981-47.981 c-18.543-18.543-48.653-18.543-67.259,0l-45.961,45.961l107.59,107.59l53.611-53.611 C532.495,100.753,532.495,77.559,518.113,63.177z M0.3,512.69c-1.958,8.812,5.998,16.708,14.811,14.565l119.891-29.069 L27.473,390.597L0.3,512.69z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
</g></g> </svg>
|
|
||||||
|
Before Width: | Height: | Size: 737 B |
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 31.059 31.059" style="enable-background:new 0 0 31.059 31.059;" xml:space="preserve" width="512px" height="512px" class=""><g><g>
|
|
||||||
<g>
|
|
||||||
<path d="M15.529,31.059C6.966,31.059,0,24.092,0,15.529C0,6.966,6.966,0,15.529,0 c8.563,0,15.529,6.966,15.529,15.529C31.059,24.092,24.092,31.059,15.529,31.059z M15.529,1.774 c-7.585,0-13.755,6.171-13.755,13.755s6.17,13.754,13.755,13.754c7.584,0,13.754-6.17,13.754-13.754S23.113,1.774,15.529,1.774z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path d="M21.652,16.416H9.406c-0.49,0-0.888-0.396-0.888-0.887c0-0.49,0.397-0.888,0.888-0.888h12.246 c0.49,0,0.887,0.398,0.887,0.888C22.539,16.02,22.143,16.416,21.652,16.416z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path d="M15.529,22.539c-0.49,0-0.888-0.397-0.888-0.887V9.406c0-0.49,0.398-0.888,0.888-0.888 c0.49,0,0.887,0.398,0.887,0.888v12.246C16.416,22.143,16.02,22.539,15.529,22.539z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
|
||||||
</g>
|
|
||||||
</g></g> </svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,13 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 729.837 729.838" style="enable-background:new 0 0 729.837 729.838;" xml:space="preserve"><g><g>
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path d="M589.193,222.04c0-6.296,5.106-11.404,11.402-11.404S612,215.767,612,222.04v437.476c0,19.314-7.936,36.896-20.67,49.653 c-12.733,12.734-30.339,20.669-49.653,20.669H188.162c-19.315,0-36.943-7.935-49.654-20.669 c-12.734-12.734-20.669-30.313-20.669-49.653V222.04c0-6.296,5.108-11.404,11.403-11.404c6.296,0,11.404,5.131,11.404,11.404 v437.476c0,13.02,5.37,24.922,13.97,33.521c8.6,8.601,20.503,13.993,33.522,13.993h353.517c13.019,0,24.896-5.394,33.498-13.993 c8.624-8.624,13.992-20.503,13.992-33.498V222.04H589.193z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
<path d="M279.866,630.056c0,6.296-5.108,11.403-11.404,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.404,5.108,11.404,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
<path d="M376.323,630.056c0,6.296-5.107,11.403-11.403,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.403,5.108,11.403,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
<path d="M472.803,630.056c0,6.296-5.106,11.403-11.402,11.403c-6.297,0-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.107-11.404,11.404-11.404c6.296,0,11.402,5.108,11.402,11.404V630.056L472.803,630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
<path d="M273.214,70.323c0,6.296-5.108,11.404-11.404,11.404c-6.295,0-11.403-5.108-11.403-11.404 c0-19.363,7.911-36.943,20.646-49.677C283.787,7.911,301.368,0,320.73,0h88.379c19.339,0,36.92,7.935,49.652,20.669 c12.734,12.734,20.67,30.362,20.67,49.654c0,6.296-5.107,11.404-11.403,11.404s-11.403-5.108-11.403-11.404 c0-13.019-5.369-24.922-13.97-33.522c-8.602-8.601-20.503-13.994-33.522-13.994h-88.378c-13.043,0-24.922,5.369-33.546,13.97 C278.583,45.401,273.214,57.28,273.214,70.323z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
<path d="M99.782,103.108h530.273c11.189,0,21.405,4.585,28.818,11.998l0.047,0.048c7.413,7.412,11.998,17.628,11.998,28.818 v29.46c0,6.295-5.108,11.403-11.404,11.403h-0.309H70.323c-6.296,0-11.404-5.108-11.404-11.403v-0.285v-29.175 c0-11.166,4.585-21.406,11.998-28.818l0.048-0.048C78.377,107.694,88.616,103.108,99.782,103.108L99.782,103.108z M630.056,125.916H99.782c-4.965,0-9.503,2.02-12.734,5.274L87,131.238c-3.255,3.23-5.274,7.745-5.274,12.734v18.056h566.361 v-18.056c0-4.965-2.02-9.503-5.273-12.734l-0.049-0.048C639.536,127.936,635.021,125.916,630.056,125.916z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g></g> </svg>
|
|
||||||
|
Before Width: | Height: | Size: 3 KiB |
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 212.982 212.982" style="enable-background:new 0 0 212.982 212.982;" xml:space="preserve" width="512px" height="512px" class=""><g><g id="Close">
|
|
||||||
<path d="M131.804,106.491l75.936-75.936c6.99-6.99,6.99-18.323,0-25.312 c-6.99-6.99-18.322-6.99-25.312,0l-75.937,75.937L30.554,5.242c-6.99-6.99-18.322-6.99-25.312,0c-6.989,6.99-6.989,18.323,0,25.312 l75.937,75.936L5.242,182.427c-6.989,6.99-6.989,18.323,0,25.312c6.99,6.99,18.322,6.99,25.312,0l75.937-75.937l75.937,75.937 c6.989,6.99,18.322,6.99,25.312,0c6.99-6.99,6.99-18.322,0-25.312L131.804,106.491z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
|
||||||
</g></g> </svg>
|
|
||||||
|
Before Width: | Height: | Size: 816 B |
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Life Towers</title>
|
<title>Life Towers</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#5d576b" />
|
<meta name="theme-color" content="#5d576b" />
|
||||||
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,38 +15,6 @@
|
||||||
src: url('assets/fonts/raleway-400.woff2') format('woff2');
|
src: url('assets/fonts/raleway-400.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('assets/fonts/roboto-300.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('assets/fonts/roboto-400.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('assets/fonts/roboto-500.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Material Icons';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: block;
|
|
||||||
src: url('assets/fonts/material-icons.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@import 'library/main';
|
@import 'library/main';
|
||||||
|
|
||||||
$line-height: 2px;
|
$line-height: 2px;
|
||||||
|
|
@ -56,7 +24,7 @@ $line-height: 2px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus:not(:focus-visible) {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue