This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
commit f74ee43cb4
196 changed files with 16749 additions and 29973 deletions

7
frontend/.browserslistrc Normal file
View file

@ -0,0 +1,7 @@
last 2 Chrome versions
last 2 Firefox versions
last 2 Safari versions
last 2 Edge versions
Firefox ESR
not dead
not IE 11

View file

@ -8,6 +8,10 @@ indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

36
frontend/.gitignore vendored
View file

@ -1,21 +1,24 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output
# Compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# Playwright
/test-results
/playwright-report
/playwright/.cache
/visuals
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
/.idea
.idea/
.project
.classpath
.c9/
@ -23,26 +26,25 @@ speed-measure-plugin.json
.settings/
*.sublime-workspace
# IDE - VSCode
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# misc
/.sass-cache
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
__screenshots__/
# System Files
# System files
.DS_Store
Thumbs.db
.firebase

View file

@ -1,8 +1,12 @@
{
"printWidth": 120,
"printWidth": 100,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
frontend/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
frontend/.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
frontend/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

View file

@ -1,27 +1,59 @@
# Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.3.8.
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
## Build
```bash
ng generate component component-name
```
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
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
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
For end-to-end (e2e) testing, run:
## Further help
```bash
ng e2e
```
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
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.

View file

@ -1,86 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": [],
"es5BrowserSupport": true
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "20mb",
"maximumError": "50mb"
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "16kB",
"maximumError": "32kB"
}
]
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "frontend:build"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"browserTarget": "frontend:build:production"
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "frontend:build"
}
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"builder": "@angular-eslint/builder:lint",
"options": {
"tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "frontend"
}
}
}

View file

@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
/**
* Smoke test: drives the legacy-styled UI end-to-end.
*
* docker compose -f docker-compose.dev.yml up --build -d
* PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test
* docker compose -f docker-compose.dev.yml down -v
*/
test.describe('Life Towers smoke test', () => {
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
await page.goto('/');
// Wait for the empty-state hint that means init() completed.
await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 });
// Create a page via the select-add dropdown.
await page.locator('lt-select-add .top').first().click();
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
// The page name now appears in the dropdown top.
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies');
// Create a tower.
await page.locator('img[alt="Add tower"]').click();
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
await page.locator('lt-tower-settings button[type="submit"]').click();
// Tower's name input is rendered with the tower name as its value.
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
// Create a block.
await page.locator('img[alt="Add block"]').first().click();
// The tag input is inside an lt-select-add — open it and add a tag.
await page.locator('lt-block-edit lt-select-add .top').click();
await page
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
.fill('learn');
await page
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
.press('Enter');
await page.locator('textarea[placeholder="Write a description here…"]').fill(
'Modernise the towers app',
);
await page.getByRole('button', { name: 'Create and exit' }).click();
// New block is pending → appears in the tasks accordion.
// (Tasks header shows N tasks.)
await expect(page.locator('lt-tasks')).toContainText('1');
// Open the tasks accordion + click the task to edit it, then flip done.
await page.locator('lt-tasks .container').click();
await page.locator('lt-tasks .task-container').click();
// Toggle done in the block-edit modal — the right label is "Done".
const putLanded = page.waitForResponse(
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
);
// Toggle uses verbose labels — "Goal accomplished" flips it to done.
await page
.locator('lt-block-edit lt-toggle span')
.filter({ hasText: 'Goal accomplished' })
.click();
await page.getByRole('button', { name: 'Create and exit' }).click();
await putLanded;
// Done block now appears as a colored square in the tower's falling stack.
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
// Reload — everything must come back from the server.
await page.reload();
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
timeout: 15000,
});
await expect(page.getByDisplayValue('Side projects')).toBeVisible();
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
});
});

View file

@ -0,0 +1,160 @@
import { test } from '@playwright/test';
/**
* Visual capture: drives the UI into key states and writes screenshots
* for human review of the legacy-styled design.
*/
test.describe('Life Towers visuals', () => {
test('capture key UI states', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('text=Add a new page to get started!', { timeout: 15000 });
await page.screenshot({ path: 'visuals/01-empty-state.png', fullPage: true });
// Open the page dropdown (without creating a page yet).
await page.locator('lt-select-add .top').first().click();
// Wait for the slide-down animation to finish (200ms transform + buffer).
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/02-page-dropdown-open.png', fullPage: true });
// Create the page.
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await page.locator('body').click({ position: { x: 10, y: 400 } });
await page.waitForTimeout(200);
// ── Add a tower ─────────────────────────────────────────────────────────
await page.locator('img[alt="Add tower"]').click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.locator('input[placeholder="Tower name…"]').fill('Reading');
await page.screenshot({ path: 'visuals/03-new-tower-modal.png', fullPage: true });
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
// ── Open the block-edit carousel (empty) ────────────────────────────────
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/04-block-edit-carousel-empty.png', fullPage: true });
// Fill in the create card. Make this one a NOT-finished task so the
// tasks accordion has a row to show off (with the tickbox).
const createCard = page.locator('lt-block-edit .create-card');
await createCard.locator('lt-select-add .top').click();
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('novel');
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await createCard
.locator('textarea[placeholder="Write a description here…"]')
.fill('Finish The Brothers Karamazov');
// Toggle to "Task hasn't been finished yet" so this becomes a pending task.
await createCard
.locator('lt-toggle span')
.filter({ hasText: "This task hasn't been finished yet" })
.click();
await page.getByRole('button', { name: 'Create and exit' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
// Open the tasks accordion to show the new tickbox.
await page.waitForTimeout(200);
await page.locator('lt-tasks .container').click();
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
// Add a couple more blocks.
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
const cc = page.locator('lt-block-edit .create-card');
// Re-use existing tag "novel" — click it from the select-add list.
await cc.locator('lt-select-add .top').click();
await page.waitForTimeout(100);
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
await page.getByRole('button', { name: 'Create and exit' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
}
// Wait for the falling animation to finish.
await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/05-populated-falling-blocks.png', fullPage: true });
// ── Open carousel WITH existing blocks ──────────────────────────────────
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/06-carousel-with-existing.png', fullPage: true });
// Scroll one card to the left (to focus an existing block, showing neighbour mask).
await page.locator('lt-block-edit').evaluate((el) => {
const c = el.querySelector('.carousel') as HTMLElement | null;
if (c) c.scrollBy({ left: -400, behavior: 'instant' as ScrollBehavior });
});
await page.waitForTimeout(400);
await page.screenshot({ path: 'visuals/07-carousel-existing-focused.png', fullPage: true });
// Close the carousel via the exit X.
await page.locator('lt-block-edit .exit').first().click({ force: true });
await page.waitForSelector('section.modal', { state: 'detached' });
// ── Add a second tower so we can show drag-drop ─────────────────────────
await page.locator('img[alt="Add tower"]').click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(300);
// ── Drag a tower over the trash (capture mid-drag) ──────────────────────
const firstTowerHandle = page.locator('lt-tower').first();
const trash = page.locator('img.trash');
const tb = await firstTowerHandle.boundingBox();
if (tb) {
// Pick up the tower from its center, then move toward the trash.
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
await page.mouse.down();
// Move in two stages — first to dislodge cdkDrag, then over the trash.
await page.mouse.move(tb.x + tb.width / 2 + 30, tb.y + tb.height / 2 + 30, { steps: 8 });
const trb = await trash.boundingBox();
if (trb) {
await page.mouse.move(trb.x + trb.width / 2, trb.y + trb.height / 2, { steps: 12 });
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/08-dragging-over-trash.png', fullPage: true });
}
// Actually release over the trash to trigger the confirm-delete modal.
await page.mouse.up();
await page.waitForTimeout(400);
await page.screenshot({ path: 'visuals/08b-confirm-delete-modal.png', fullPage: true });
// Cancel — don't actually delete the tower.
await page.locator('.confirm-buttons button').filter({ hasText: 'Cancel' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
}
// ── Move the date-range slider thumb — blocks should ascend out ────────
const slider = page.locator('lt-double-slider input[type="range"]').first();
const sb = await slider.boundingBox();
if (sb) {
// Drag the left thumb from 0 toward the right (~80% of slider width).
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2);
await page.mouse.down();
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2, { steps: 16 });
await page.mouse.up();
// Wait long enough for the 1.5s ascend transition.
await page.waitForTimeout(1700);
await page.screenshot({ path: 'visuals/09-date-filter-ascended.png', fullPage: true });
// Restore the range and screenshot the descend back to rest.
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2);
await page.mouse.down();
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(1700);
await page.screenshot({ path: 'visuals/10-date-filter-restored.png', fullPage: true });
}
// ── Settings modal ──────────────────────────────────────────────────────
await page.getByRole('button', { name: 'Settings' }).click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true });
});
});

55
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,55 @@
// @ts-check
const eslint = require('@eslint/js');
const { defineConfig } = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = defineConfig([
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: ['lt', 'app'],
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: ['lt', 'app'],
style: 'kebab-case',
},
],
// Allow output named 'close' - it is not a standard DOM event on custom elements
'@angular-eslint/no-output-native': 'off',
// Allow empty arrow functions in catch clauses for fire-and-forget patterns
'@typescript-eslint/no-empty-function': 'off',
},
},
{
files: ['**/*.html'],
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
rules: {
// Relax label association check our modal inputs are associated via id/for
'@angular-eslint/template/label-has-associated-control': 'warn',
},
},
{
// Vitest test files relax unused-vars for test utilities
files: ['**/*.vitest.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
]);

View file

@ -1,13 +0,0 @@
{
"hosting": {
"public": "dist/frontend",
"site": "towers-schmelczer-dev",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

28
frontend/ngsw-config.json Normal file
View file

@ -0,0 +1,28 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"]
}
}
]
}

30379
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,47 +5,39 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --prod",
"format:fix": "pretty-quick --staged",
"precommit": "run-s format:fix lint",
"format:check": "prettier --config ./.prettierrc --list-different \"src/{app,environments,assets}/**/*{.ts,.js,.json,.css,.scss}\"",
"watch": "ng build --watch --configuration development",
"test": "vitest --run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"lint": "ng lint"
},
"private": true,
"packageManager": "npm@10.9.2",
"dependencies": {
"@angular/animations": "^7.2.15",
"@angular/cdk": "^7.3.7",
"@angular/common": "~7.2.0",
"@angular/compiler": "~7.2.0",
"@angular/core": "~7.2.0",
"@angular/forms": "~7.2.0",
"@angular/material": "^7.3.7",
"@angular/platform-browser": "~7.2.0",
"@angular/platform-browser-dynamic": "~7.2.0",
"@angular/router": "~7.2.0",
"core-js": "^2.5.4",
"ng": "^0.0.0",
"rxjs": "~6.3.3",
"tslib": "^1.10.0",
"uuid": "^3.3.3",
"zone.js": "~0.8.26"
"@angular/cdk": "^21.2.13",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.13.10",
"@angular/cli": "~7.3.8",
"@angular/compiler-cli": "~7.2.0",
"@angular/language-service": "~7.2.0",
"@types/jasmine": "~2.8.8",
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.5.0",
"husky": "^3.0.4",
"npm-run-all": "^4.1.5",
"prettier": "^1.18.2",
"pretty-quick": "^1.11.1",
"sass": "^1.32.5",
"ts-node": "~7.0.0",
"tslint": "~5.11.0",
"typescript": "~3.2.2"
"@angular/build": "^21.2.13",
"@angular/cli": "^21.2.13",
"@angular/compiler-cli": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@vitest/coverage-v8": "^4.1.7",
"angular-eslint": "21.4.0",
"eslint": "^10.3.0",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"typescript-eslint": "8.59.2",
"vitest": "^4.1.7"
}
}

View file

@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
/**
* E2E tests target the full stack served by docker-compose.dev.yml at
* http://localhost:8000 (FastAPI serving both the API and the built SPA).
* Override with PLAYWRIGHT_BASE_URL to point at a different deployment.
*
* docker compose -f docker-compose.dev.yml up --build -d
* npm run test:e2e
* docker compose -f docker-compose.dev.yml down -v
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

7
frontend/proxy.conf.json Normal file
View file

@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": false
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 292.362 292.362" style="enable-background:new 0 0 292.362 292.362;" xml:space="preserve" class=""><g><g>
<path d="M286.935,69.377c-3.614-3.617-7.898-5.424-12.848-5.424H18.274c-4.952,0-9.233,1.807-12.85,5.424 C1.807,72.998,0,77.279,0,82.228c0,4.948,1.807,9.229,5.424,12.847l127.907,127.907c3.621,3.617,7.902,5.428,12.85,5.428 s9.233-1.811,12.847-5.428L286.935,95.074c3.613-3.617,5.427-7.898,5.427-12.847C292.362,77.279,290.548,72.998,286.935,69.377z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 746 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 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>

After

Width:  |  Height:  |  Size: 737 B

View file

@ -0,0 +1,12 @@
<?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>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,13 @@
<?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>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" 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>

After

Width:  |  Height:  |  Size: 816 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,59 @@
{
"name": "Life Towers",
"short_name": "Towers",
"display": "standalone",
"scope": "./",
"start_url": "./",
"theme_color": "#a2666f",
"background_color": "#fffcf0",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View file

@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PagesComponent } from './components/pages/pages.component';
const routes: Routes = [
{
path: '',
component: PagesComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}

View file

@ -1,4 +0,0 @@
<main (click)="cancelService.cancelAll()">
<app-modal></app-modal>
<router-outlet></router-outlet>
</main>

View file

@ -1,3 +0,0 @@
main {
height: 100%;
}

View file

@ -1,24 +0,0 @@
import { ChangeDetectionStrategy, Component, DoCheck } from '@angular/core';
import { CancelService } from './services/cancel.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements DoCheck {
title = 'life';
constructor(public cancelService: CancelService) {
window.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this.cancelService.cancelAll();
}
});
}
ngDoCheck() {
// console.log('app change detection');
}
}

View file

@ -0,0 +1,22 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
isDevMode,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter([]),
provideHttpClient(withFetch()),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};

343
frontend/src/app/app.html Normal file
View file

@ -0,0 +1,343 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: block;
height: 100dvh;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->

View file

@ -1,51 +0,0 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { PageComponent } from './components/pages/page/page.component';
import { TowerComponent } from './components/pages/page/tower/tower.component';
import { DoubleSliderComponent } from './components/shared/double-slider/double-slider.component';
import { PagesComponent } from './components/pages/pages.component';
import { SelectAddComponent } from './components/shared/select-add/select-add.component';
import { ModalComponent } from './components/modal/modal.component';
import { FormsModule } from '@angular/forms';
import { BlockComponent } from './components/pages/page/tower/block/block.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { SettingsComponent } from './components/modal/modals/settings/settings.component';
import { RemoveTowerComponent } from './components/modal/modals/remove-tower/remove-tower.component';
import { RemovePageComponent } from './components/modal/modals/remove-page/remove-page.component';
import { GetStartedComponent } from './components/modal/modals/get-started/get-started.component';
import { ToggleComponent } from './components/shared/toggle/toggle.component';
import { TasksComponent } from './components/pages/page/tower/tasks/tasks.component';
import { ColorPipe } from './pipes/color.pipe';
import { BlocksComponent } from './components/modal/modals/blocks/blocks.component';
import { FormatDatePipe } from './pipes/format-date.pipe';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
AppComponent,
PageComponent,
BlockComponent,
TowerComponent,
DoubleSliderComponent,
PagesComponent,
SelectAddComponent,
ModalComponent,
BlockComponent,
SettingsComponent,
RemoveTowerComponent,
RemovePageComponent,
GetStartedComponent,
ToggleComponent,
TasksComponent,
ColorPipe,
BlocksComponent,
FormatDatePipe
],
imports: [BrowserModule, AppRoutingModule, FormsModule, BrowserAnimationsModule, DragDropModule, HttpClientModule],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}

18
frontend/src/app/app.ts Normal file
View file

@ -0,0 +1,18 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { StoreService } from './services/store.service';
import { PagesComponent } from './components/pages/pages.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [PagesComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<lt-pages />`,
})
export class App implements OnInit {
private readonly store = inject(StoreService);
ngOnInit(): void {
this.store.init();
}
}

View file

@ -0,0 +1,43 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
/**
* A block rendered as a small COLORED SQUARE (1/6 of tower width).
* Only DONE blocks appear here; pending blocks appear in the tasks accordion.
* Clicking opens the block-edit modal in the parent tower component.
*/
@Component({
selector: 'lt-block',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [style.background-color]="color()" (click)="clicked.emit()"></div>
`,
styles: `
@import '../../../library/main';
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6);
div {
position: absolute;
width: 100%;
height: 100%;
@include gravitate();
}
}
`,
})
export class BlockComponent {
readonly block = input.required<Block>();
readonly baseColor = input.required<HslColor>();
/** Emits when the square is clicked — parent opens the block-edit modal. */
readonly clicked = output<void>();
readonly color = computed(() => getColorOfTag(this.block().tag, this.baseColor()));
}

View file

@ -0,0 +1,521 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
inject,
signal,
computed,
effect,
viewChild,
ElementRef,
AfterViewInit,
HostListener,
untracked,
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { ToggleComponent } from '../shared/toggle/toggle.component';
import { getColorOfTag } from '../../utils/color';
export interface BlockEditSave {
/** null = create a new block */
id: string | null;
tag: string;
description: string;
is_done: boolean;
}
interface EditedValue {
tag: string;
description: string;
is_done: boolean;
}
@Component({
selector: 'lt-block-edit',
standalone: true,
imports: [SelectAddComponent, ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
#container
class="carousel"
(scroll)="onScroll()"
(click)="onBackdropClick($event)"
>
<div class="card placeholder"></div>
@for (b of blocks(); track b.id; let i = $index) {
<div
class="card"
[class.active]="activeIdx() === i + 1"
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
(click)="onCardClick(i + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<div
class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)"
></div>
<h1>{{ formatDate(b.created_at) }}</h1>
</div>
<div class="select-add-container">
<lt-select-add
[items]="tags()"
[selected]="editedFor(b.id).tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Tag this item…"
(select)="updateTag(b.id, $event)"
(add)="updateTag(b.id, $event)"
/>
</div>
<textarea
placeholder="Write a description here…"
[value]="editedFor(b.id).description"
(input)="updateDescription(b.id, $any($event.target).value)"
(blur)="flushExisting(b.id)"
></textarea>
<div>
<lt-toggle
[checked]="editedFor(b.id).is_done"
(checkedChange)="updateDone(b.id, $event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/>
</div>
<div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
</div>
</div>
}
<div
class="card create-card"
[class.active]="activeIdx() === blocks().length + 1"
[class.near-active]="activeIdx() === blocks().length"
(click)="onCardClick(blocks().length + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<div
class="block-dot"
[style.background-color]="colorOfNewTag()"
></div>
<h1>Create now</h1>
</div>
<div class="select-add-container">
<lt-select-add
[items]="tags()"
[selected]="newValue().tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Set a category…"
(select)="updateNewTag($event)"
(add)="updateNewTag($event)"
/>
</div>
<textarea
placeholder="Write a description here…"
[value]="newValue().description"
(input)="updateNewDescription($any($event.target).value)"
></textarea>
<div>
<lt-toggle
[checked]="newValue().is_done"
(checkedChange)="updateNewDone($event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/>
</div>
<div class="bottom">
<button
(click)="submitNew(); $event.stopPropagation()"
[disabled]="!newValue().tag"
>
Create and exit
</button>
</div>
</div>
<div class="card placeholder"></div>
</section>
`,
styles: `
@import '../../../library/main';
:host {
@include center-child();
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001; // above modal backdrop (10000)
}
.carousel {
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.card {
@include card();
box-shadow: $shadow;
display: block;
transform-origin: center center;
flex: 0 0 auto;
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) {
width: 300px;
opacity: 1 !important;
}
box-sizing: border-box;
padding: var(--large-padding);
margin: calc(var(--large-padding) / 2);
position: relative;
@include inner-spacing(var(--large-padding));
opacity: 0.6;
transition: opacity $long-animation-time;
&.near-active {
cursor: pointer;
opacity: 0.85;
}
&.active {
opacity: 1;
}
.mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
@include card();
opacity: 1;
transition: opacity $long-animation-time;
pointer-events: none;
@media (max-width: $mobile-width) {
opacity: 0 !important;
}
}
&.active .mask {
opacity: 0;
}
&.near-active .mask {
opacity: 0.55;
}
&:first-child {
margin-left: var(--large-padding);
}
&.placeholder {
opacity: 0 !important;
width: 60vw;
max-width: 60vw;
box-shadow: none;
background: transparent;
}
.header {
@include center-child();
position: relative;
.exit {
position: absolute;
left: 0;
@include exit();
}
.block-dot {
@include square(12px);
margin-right: 10px;
border-radius: 2px;
}
}
.select-add-container {
// When the create card has no tag chosen, glow the dropdown red as a
// gentle "required" cue — matches the legacy ghost-button affordance.
&.required-empty lt-select-add {
box-shadow: 0 0 0 0.75px rgba(181, 63, 63, 0.5);
border-radius: var(--border-radius);
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
}
}
}
`,
})
export class BlockEditComponent implements AfterViewInit {
readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]);
readonly baseColor = input.required<HslColor>();
/** Default for `is_done` on the create card. */
readonly defaultDone = input<boolean>(true);
readonly save = output<BlockEditSave>();
readonly delete = output<string>();
readonly close = output<void>();
private readonly container =
viewChild<ElementRef<HTMLElement>>('container');
// Per-block edited values, keyed by block ID.
readonly editedValues = signal<Map<string, EditedValue>>(new Map());
// Pending new block being authored on the create card.
readonly newValue = signal<EditedValue>({
tag: '',
description: '',
is_done: true,
});
// 1-based index of the centered card. 0/N+2 are placeholders.
readonly activeIdx = signal(1);
private scrollToken = 0;
constructor() {
// Seed editedValues from input blocks (and re-seed if input changes).
effect(() => {
const bs = this.blocks();
const m = new Map<string, EditedValue>();
for (const b of bs) {
m.set(b.id, {
tag: b.tag,
description: b.description,
is_done: b.is_done,
});
}
untracked(() => this.editedValues.set(m));
});
// Seed the newValue tag from tags input on first run.
effect(() => {
const t = this.tags();
untracked(() => {
const cur = this.newValue();
if (!cur.tag && t.length > 0) {
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
} else if (!cur.tag) {
this.newValue.set({ ...cur, is_done: this.defaultDone() });
}
});
});
}
ngAfterViewInit(): void {
// Position scroll on the focused card (or the create card if none).
queueMicrotask(() => {
const blocks = this.blocks();
const focusId = this.activeBlockId();
const focusIdx = focusId
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
: blocks.length;
this.scrollToChild(focusIdx + 1, false);
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
editedFor(id: string): EditedValue {
return (
this.editedValues().get(id) ?? {
tag: '',
description: '',
is_done: false,
}
);
}
colorOfTagForBlock(id: string): string {
const v = this.editedFor(id);
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
}
colorOfNewTag = computed(() => {
const t = this.newValue().tag;
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
});
formatDate(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
// ── Existing-card mutations (auto-save on each change) ─────────────────────
updateTag(id: string, tag: string): void {
this.patchEdited(id, { tag });
this.flushExisting(id);
}
updateDescription(id: string, description: string): void {
this.patchEdited(id, { description });
// Description flush deferred to (blur) to avoid PUT per keystroke.
}
updateDone(id: string, is_done: boolean): void {
this.patchEdited(id, { is_done });
this.flushExisting(id);
}
private patchEdited(id: string, patch: Partial<EditedValue>): void {
this.editedValues.update((m) => {
const v = m.get(id);
if (!v) return m;
const next = new Map(m);
next.set(id, { ...v, ...patch });
return next;
});
}
flushExisting(id: string): void {
const v = this.editedFor(id);
if (!v.tag) return; // Skip empty saves
this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done });
}
onDelete(id: string): void {
this.delete.emit(id);
}
// ── Create-card mutations ──────────────────────────────────────────────────
updateNewTag(tag: string): void {
this.newValue.update((v) => ({ ...v, tag }));
}
updateNewDescription(description: string): void {
this.newValue.update((v) => ({ ...v, description }));
}
updateNewDone(is_done: boolean): void {
this.newValue.update((v) => ({ ...v, is_done }));
}
submitNew(): void {
const v = this.newValue();
if (!v.tag) return;
this.save.emit({
id: null,
tag: v.tag,
description: v.description,
is_done: v.is_done,
});
this.close.emit();
}
// ── Scroll handling ────────────────────────────────────────────────────────
onCardClick(idx: number): void {
if (idx !== this.activeIdx()) {
this.scrollToChild(idx, true);
}
}
/** Close the carousel when the user clicks anywhere that isn't a real card. */
onBackdropClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target && !target.closest('.card:not(.placeholder)')) {
this.close.emit();
}
}
onScroll(): void {
const token = ++this.scrollToken;
setTimeout(() => {
if (token === this.scrollToken) this.adjustPosition();
}, 150);
}
@HostListener('window:resize')
onResize(): void {
this.scrollToChild(this.activeIdx(), false);
}
private scrollToChild(idx: number, smooth: boolean): void {
const container = this.container()?.nativeElement;
if (!container) return;
const card = container.children.item(idx) as HTMLElement | null;
if (!card) return;
const left =
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
this.activeIdx.set(idx);
}
private adjustPosition(): void {
const container = this.container()?.nativeElement;
if (!container) return;
const center = container.scrollLeft + container.clientWidth / 2;
let nearestIdx = 1;
let minDist = Infinity;
// children[0] and children[last] are the placeholders — skip.
for (let i = 1; i < container.children.length - 1; i++) {
const child = container.children.item(i) as HTMLElement;
const c = child.offsetLeft + child.offsetWidth / 2;
const d = Math.abs(c - center);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
if (nearestIdx !== this.activeIdx()) {
this.scrollToChild(nearestIdx, true);
}
}
}

View file

@ -1,11 +0,0 @@
<section
(click)="modalService.cancel()"
class="{{ modalService.active ? 'active' : '' }}"
[ngSwitch]="modalService.active?.type"
>
<app-blocks (save)="save = $event" (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.blocks"></app-blocks>
<app-remove-tower (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removeTower"></app-remove-tower>
<app-settings (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.settings"></app-settings>
<app-get-started (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.getStarted"></app-get-started>
<app-remove-page (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removePage"></app-remove-page>
</section>

View file

@ -1,29 +0,0 @@
@import '../../../styles';
section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
transition: opacity 300ms;
&:not(.active) {
opacity: 0;
pointer-events: none;
}
button {
margin-top: var(--medium-padding);
}
}

View file

@ -1,25 +1,94 @@
import { Component } from '@angular/core';
import { ModalService, ModalType } from '../../services/modal.service';
import { CancelService } from '../../services/cancel.service';
import {
Component,
ChangeDetectionStrategy,
output,
signal,
AfterViewInit,
OnDestroy,
viewChild,
ElementRef,
inject,
} from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
import { ModalStateService } from '../../services/modal-state.service';
@Component({
selector: 'app-modal',
templateUrl: './modal.component.html',
styleUrls: ['./modal.component.scss']
})
export class ModalComponent {
ModalType = ModalType;
selector: 'lt-modal',
standalone: true,
imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
class="modal"
[class.active]="active()"
(click)="onBackdropClick($event)"
>
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
<ng-content></ng-content>
</div>
</section>
`,
styles: `
@import '../../../library/main';
save: () => void = null;
section.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
transition: opacity 300ms;
opacity: 1;
constructor(public modalService: ModalService, private cancelService: CancelService) {
this.cancelService.subscribe(this, () => {
if (this.save) {
this.save();
this.save = null;
} else {
this.modalService.cancel();
&:not(.active) {
opacity: 0;
pointer-events: none;
}
});
button {
margin-top: var(--medium-padding);
}
}
`,
})
export class ModalComponent implements AfterViewInit, OnDestroy {
readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick
// so the 300ms opacity transition fires on entry (0 → 1).
readonly active = signal(false);
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
private previousFocus: HTMLElement | null = null;
private escListener!: (e: KeyboardEvent) => void;
private readonly modalState = inject(ModalStateService);
ngAfterViewInit(): void {
this.previousFocus = document.activeElement as HTMLElement;
// Track open state so towers can be locked while any modal is mounted.
this.modalState.open();
// Defer one tick so the opacity transition runs (0 → 1).
setTimeout(() => this.active.set(true), 0);
}
ngOnDestroy(): void {
this.modalState.close();
this.previousFocus?.focus();
}
onBackdropClick(event: MouseEvent): void {
const dialog = this.dialogRef()?.nativeElement;
if (dialog && !dialog.contains(event.target as Node)) {
this.onClose();
}
}
onClose(): void {
this.close.emit();
}
}

View file

@ -1,85 +0,0 @@
<section #container *ngIf="tower">
<div class="card placeholder"></div>
<div
*ngFor="let i of range({ max: blocks.length })"
(click)="$event.stopPropagation(); scrollToChild(i + 1)"
class="card {{ i + 1 === activeChild ? 'active' : '' }} {{
i + 2 === activeChild || i === activeChild ? 'near-active' : ''
}}"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(editedValues[i].tag) | color }"></div>
<h1 [innerText]="editedValues[i]?.created | formatDate"></h1>
</div>
<div class="select-add-container">
<app-select-add
class="select"
[options]="tower.tags"
[default]="editedValues[i].tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="'Tag this item…'"
(value)="editedValues[i].tag = $event"
></app-select-add>
</div>
<textarea placeholder="Write a description here…" [(ngModel)]="editedValues[i].description"></textarea>
<div>
<app-toggle
[beforeText]="'This task hasn\'t been finished yet'"
[afterText]="'Goal already accomplished'"
[default]="blocks[i].isDone"
(value)="editedValues[i].isDone = $event"
></app-toggle>
</div>
</div>
<div
(click)="$event.stopPropagation(); scrollToChild(blocks.length + 1)"
class="card {{ blocks.length + 1 === activeChild ? 'active' : '' }} {{
blocks.length === activeChild ? 'near-active' : ''
}} "
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(top(editedValues).tag) | color }"></div>
<h1>Create now</h1>
</div>
<div class="select-add-container">
<app-select-add
class="select"
[options]="tower.tags"
[default]="tower.tags.length ? tower.tags[0] : null"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="'Set a category…'"
[newValuePlaceholder]="'Add a category…'"
(value)="top(editedValues).tag = $event"
></app-select-add>
</div>
<textarea placeholder="Write a description here…" [(ngModel)]="top(editedValues).description"></textarea>
<div>
<app-toggle
[beforeText]="'This task hasn\'t been finished yet'"
[afterText]="'Goal already accomplished'"
[default]="onlyDone"
(value)="top(editedValues).isDone = $event"
></app-toggle>
</div>
<div class="bottom">
<button (click)="submitAdd()" [disabled]="!top(editedValues).tag">Create and exit</button>
</div>
</div>
<div class="card placeholder"></div>
</section>

View file

@ -1,175 +0,0 @@
@import '../../../../../styles';
:host {
@include center-child();
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow-x: auto;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
section {
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
}
}
.card {
@include card();
box-shadow: $shadow;
display: block;
transform-origin: center center;
flex: 0 0 auto;
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) {
width: 300px;
opacity: 1 !important;
}
box-sizing: border-box;
padding: var(--large-padding);
margin: calc(var(--large-padding) / 2);
position: relative;
&.near-active {
cursor: pointer;
}
.mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
@include card();
@media (max-width: $mobile-width) {
opacity: 0 !important;
}
}
&:first-child {
margin-left: var(--large-padding);
}
&.placeholder {
opacity: 0 !important;
width: 60vw;
max-width: 60vw;
}
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
position: relative;
.exit {
position: absolute;
left: 0;
@include exit();
}
.block {
@include square(12px);
margin-right: 10px;
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
transition: opacity $short-animation-time;
&.hidden {
opacity: 0;
}
}
.edit {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
opacity: 0.25;
cursor: pointer;
img {
@include square(16px);
}
transition: opacity $short-animation-time;
&:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * #{$line-height});
left: 0;
height: $line-height;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover {
opacity: 0.5;
}
&:hover {
&:before {
width: 100% !important;
}
}
}
&.active {
&:before {
width: 100% !important;
}
}
&.active {
opacity: 1;
}
}
}
}
.card:last-child:after {
content: '';
height: 1px;
width: var(--large-padding);
right: calc(-1 * var(--large-padding));
display: block;
position: absolute;
}

View file

@ -1,183 +0,0 @@
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { Tower } from '../../../../model/tower';
import { Observable } from 'rxjs/internal/Observable';
import { Block } from '../../../../model/block';
import { IBlock } from '../../../../interfaces/persistance/block';
import { CancelService } from '../../../../services/cancel.service';
import { range } from 'src/app/utils/range';
import { top } from 'src/app/utils/top';
@Component({
selector: 'app-blocks',
templateUrl: './blocks.component.html',
styleUrls: ['./blocks.component.scss']
})
export class BlocksComponent implements OnInit, OnDestroy {
readonly range = range;
readonly top = top;
tower: Tower;
editedValues: Array<Partial<IBlock>>;
endOfScrollToken = 0;
activeChild: number;
scrollMayEnd = true;
onlyDone: boolean;
@ViewChild('container') container: ElementRef;
private intervalID: number;
constructor(
public modalService: ModalService,
private cancelService: CancelService,
private changeDetector: ChangeDetectorRef,
private component: ElementRef
) {
window.addEventListener('resize', this.onScroll.bind(this));
}
@Output() save: EventEmitter<() => void> = new EventEmitter();
get blocks(): Array<Block> {
return this.tower.blocks.filter(b => b.isDone === this.onlyDone);
}
@HostListener('click') cancel() {
this.cancelService.cancelAll();
}
@HostListener('touchstart') fingerDown() {
this.scrollMayEnd = false;
}
@HostListener('touchend') fingerUp() {
this.scrollMayEnd = true;
this.onScroll();
}
@HostListener('scroll') onScroll() {
const newToken = ++this.endOfScrollToken;
setTimeout(() => {
if (newToken === this.endOfScrollToken && this.scrollMayEnd) {
this.adjustPosition();
}
}, 150);
this.animateScroll();
}
ngOnInit() {
const {
tower$,
onlyDone,
startBlock
}: { tower$: Observable<Tower>; onlyDone: boolean; startBlock: Block } = this.modalService.active.input;
this.save.emit(() => this.submitChange());
this.intervalID = setInterval(() => this.changeDetector.detectChanges(), 1000);
this.onlyDone = onlyDone;
const subscription = tower$.subscribe(value => {
if (value) {
this.tower = value;
this.editedValues = this.blocks.map(({ isDone, description, tag, created }) => ({
isDone,
description,
tag,
created
}));
this.editedValues.push({
tag: this.tower.tags.length ? this.tower.tags[0] : null,
isDone: this.onlyDone,
description: ''
});
setTimeout(() => {
this.scrollToChild(startBlock ? this.blocks.indexOf(startBlock) + 1 : this.blocks.length + 1, true);
subscription.unsubscribe();
});
}
});
}
animateScroll() {
if (!this.container || !this.component) {
return;
}
const c = this.component.nativeElement;
[...this.container.nativeElement.children]
.slice(1, -1)
.forEach(element =>
this.animate(
element.style,
element.querySelector('.mask').style,
Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2) /
element.clientWidth
)
);
}
animate(cardStyle, maskStyle, t: number) {
t = Math.min(2, Math.max(0, t));
cardStyle.opacity = (1.33 * (1 - t / 2)).toString();
t = Math.min(1, Math.max(0, t));
maskStyle.opacity = Math.pow(t, 0.5).toString();
maskStyle.display = t <= 0.05 ? 'none' : 'block';
}
adjustPosition() {
if (!this.container || !this.component) {
return;
}
const c = this.component.nativeElement;
const middle =
[...this.container.nativeElement.children]
.slice(1, -1)
.map(element => Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2))
.map((value, index) => (Math.abs(index + 1 - this.activeChild) === 1 ? Math.abs(value - 100) : value))
.reduce(
(middleIndex, current, currentIndex, list) => (list[middleIndex] < current ? middleIndex : currentIndex),
0
) + 1;
this.scrollToChild(middle);
}
scrollToChild(index: number, instantly?: boolean) {
this.activeChild = index;
const element = this.container.nativeElement.children[index];
this.component.nativeElement.scrollTo({
left: element.offsetLeft - (window.innerWidth / 2 - element.clientWidth / 2),
behavior: instantly ? 'auto' : 'smooth'
});
}
submitAdd() {
top(this.editedValues).created = new Date();
this.tower.addBlock(top(this.editedValues) as IBlock);
this.cancelService.cancelAll();
}
submitChange() {
this.blocks.forEach((b, i) => b.changeKeys(this.editedValues[i]));
this.modalService.submit();
}
ngOnDestroy() {
clearInterval(this.intervalID);
}
}

View file

@ -1,3 +0,0 @@
<p>
get-started works!
</p>

View file

@ -1,12 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-get-started',
templateUrl: './get-started.component.html',
styleUrls: ['./get-started.component.scss']
})
export class GetStartedComponent implements OnInit {
constructor() {}
ngOnInit() {}
}

View file

@ -1,13 +0,0 @@
<section>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Are you sure?</h1>
</div>
<p>
You are trying to remove <strong>{{ this.modalService.active.input }}</strong
>.
</p>
<button (click)="modalService.submit()">Remove</button>
</section>

View file

@ -1,30 +0,0 @@
@import '../../../../../styles';
section {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) {
width: 300px;
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
}

View file

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
@Component({
selector: 'app-remove-page',
templateUrl: './remove-page.component.html',
styleUrls: ['./remove-page.component.scss']
})
export class RemovePageComponent {
constructor(public modalService: ModalService) {}
}

View file

@ -1,14 +0,0 @@
<section>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Are you sure?</h1>
</div>
<p>
You are trying to remove
<span [ngStyle]="{ color: tower.baseColor | color }">{{ tower.name ? tower.name : 'an unnamed tower' }}</span
>.
</p>
<button (click)="modalService.submit()">Remove</button>
</section>

View file

@ -1,30 +0,0 @@
@import '../../../../../styles';
section {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) {
width: 300px;
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
}

View file

@ -1,14 +0,0 @@
import { Component } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { Tower } from '../../../../model/tower';
@Component({
selector: 'app-remove-tower',
templateUrl: './remove-tower.component.html',
styleUrls: ['./remove-tower.component.scss']
})
export class RemoveTowerComponent {
constructor(public modalService: ModalService) {}
tower: Tower = this.modalService.active.input;
}

View file

@ -1,21 +0,0 @@
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Settings</h1>
</div>
<div>
<app-toggle
[beforeText]="'Hide create tower button'"
[afterText]="'Show create tower button'"
[default]="!page.userData.hideCreateTowerButton"
(value)="page.setHideCreateTowerButton(!$event)"
></app-toggle>
</div>
<p *ngIf="page.towers.length == 5">There can be a maximum of <strong>5</strong> towers on each page.</p>
<input id="token" type="text" [(ngModel)]="token" />
<button (click)="setNewToken()">Set token</button>
<button (click)="$event.stopPropagation() || deletePage()">Delete current page</button>

View file

@ -1,42 +0,0 @@
@import '../../../../../styles';
:host {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) {
width: 300px;
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
p {
font-size: var(--medium-font-size);
}
input[type='text'] {
text-align: center;
}
button {
display: block;
}
}

View file

@ -1,56 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { DataService } from '../../../../services/data.service';
import { Page } from '../../../../model/page';
import { Data } from '../../../../model/data';
import { Subscription } from 'rxjs/internal/Subscription';
import { MapStoreService } from '../../../../services/map-store.service';
@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit, OnDestroy {
data: Data;
page: Page;
private dataSubscription: Subscription;
private pageSubscription: Subscription;
token: string;
constructor(public modalService: ModalService, private store: MapStoreService) {
this.token = store.userToken;
}
ngOnInit() {
const { data$, page$ } = this.modalService.active.input;
this.dataSubscription = data$.subscribe(d => (this.data = d));
this.pageSubscription = page$.subscribe(p => (this.page = p));
}
async deletePage() {
try {
await this.modalService.showRemovePage(this.page.name);
this.data.removePage(this.page);
this.modalService.submit();
} catch {
// pass
}
}
setNewToken() {
this.store.userToken = this.token;
}
ngOnDestroy() {
if (this.dataSubscription) {
this.dataSubscription.unsubscribe();
}
if (this.pageSubscription) {
this.pageSubscription.unsubscribe();
}
}
}

View file

@ -0,0 +1,141 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
signal,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface PageSettingsResult {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
@Component({
selector: 'lt-page-settings',
standalone: true,
imports: [ReactiveFormsModule, ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ps-name"
type="text"
formControlName="name"
maxlength="200"
autocomplete="off"
placeholder="Page name…"
/>
<div class="toggle-row">
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="hideCreateTowerButton.set($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
</div>
<div class="toggle-row">
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="keepTasksOpen.set($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button type="submit" [disabled]="form.invalid">
{{ page() ? 'Save' : 'Create page' }}
</button>
@if (page()) {
<button type="button" (click)="delete.emit()">Delete page</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
input[type='text'] {
text-align: center;
}
.toggle-row {
display: flex;
justify-content: center;
}
button {
display: block;
}
}
`,
})
export class PageSettingsComponent implements OnInit {
readonly page = input<Page | null>(null);
readonly save = output<PageSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
ngOnInit(): void {
const p = this.page();
if (p) {
this.form.patchValue({ name: p.name });
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
}
onSubmit(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({
name: v.name ?? '',
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
}

View file

@ -0,0 +1,266 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface UpdatePagePayload {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
const UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Component({
selector: 'lt-settings',
standalone: true,
imports: [ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2>
@if (page()) {
<section class="page-section">
<h3>This page</h3>
<input
type="text"
[value]="pageName()"
(blur)="onRenamePage($any($event.target).value)"
placeholder="Page name…"
maxlength="200"
autocomplete="off"
aria-label="Page name"
/>
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
<button class="danger" type="button" (click)="deletePage.emit()">
Delete this page
</button>
</section>
<hr />
}
<section class="account-section">
<h3>Account</h3>
<p class="hint">Your token (keep it secret it IS your account)</p>
<div class="token-row">
<input
type="text"
readonly
[value]="token()"
aria-label="Your account token"
/>
<button type="button" (click)="onCopy()">Copy</button>
</div>
<p class="hint">Paste a token to switch accounts</p>
<div class="token-row">
<input
type="text"
[class.error]="tokenInputTouched() && tokenInput() && !isValidToken()"
placeholder="Paste a UUID…"
[value]="tokenInput()"
(input)="onTokenInput($any($event.target).value)"
(blur)="tokenInputTouched.set(true)"
aria-label="Paste token to switch account"
/>
<button type="button" [disabled]="!isValidToken()" (click)="onSwitch()">
Switch
</button>
</div>
@if (tokenInputTouched() && tokenInput() && !isValidToken()) {
<p class="error-message">Not a valid token. Must be a UUIDv4.</p>
}
</section>
</div>
`,
styles: `
@import '../../../library/main';
:host {
display: block;
}
.card {
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
font-size: 0;
}
h2 {
margin: 0 0 var(--large-padding) 0;
text-align: center;
}
h3 {
margin: 0 0 var(--medium-padding) 0;
font-size: var(--large-font-size);
}
section {
@include inner-spacing(var(--medium-padding));
margin-bottom: var(--large-padding);
&:last-child {
margin-bottom: 0;
}
}
hr {
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin: var(--large-padding) 0;
}
.hint {
font-size: var(--small-font-size);
color: rgba($text-color, 0.7);
margin: 0 0 4px 0;
}
.token-row {
display: flex;
gap: var(--small-padding);
align-items: center;
input {
flex: 1;
min-width: 0;
text-align: left;
}
button {
margin: 0;
flex: 0 0 auto;
}
}
input.error {
box-shadow: 0 1px #b53f3f;
}
.error-message {
color: #b53f3f;
font-size: var(--small-font-size);
margin-top: 4px;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
}
`,
})
export class SettingsComponent {
readonly token = input.required<string>();
readonly page = input<Page | null>(null);
readonly close = output<void>();
readonly switchAccount = output<string>();
readonly updatePage = output<UpdatePagePayload>();
readonly deletePage = output<void>();
// Page-settings state — seeded from page() input
readonly pageName = signal('');
readonly hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
// Token-switch state
readonly tokenInput = signal('');
readonly tokenInputTouched = signal(false);
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
constructor() {
effect(() => {
const p = this.page();
if (p) {
this.pageName.set(p.name);
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
});
}
onRenamePage(value: string): void {
const trimmed = value.trim();
if (!trimmed) return;
this.pageName.set(trimmed);
this.flushPageUpdate();
}
onHideCreateTowerButtonChange(value: boolean): void {
this.hideCreateTowerButton.set(value);
this.flushPageUpdate();
}
onKeepTasksOpenChange(value: boolean): void {
this.keepTasksOpen.set(value);
this.flushPageUpdate();
}
private flushPageUpdate(): void {
this.updatePage.emit({
name: this.pageName(),
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
onCopy(): void {
navigator.clipboard.writeText(this.token()).catch(() => {});
}
onTokenInput(value: string): void {
this.tokenInput.set(value.trim());
}
onSwitch(): void {
if (!this.isValidToken()) return;
this.switchAccount.emit(this.tokenInput());
this.close.emit();
}
}

View file

@ -0,0 +1,124 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
export interface TowerSettingsResult {
name: string;
base_color: HslColor;
}
@Component({
selector: 'lt-tower-settings',
standalone: true,
imports: [ReactiveFormsModule, ColorPickerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ts-name"
name="towerName"
type="text"
formControlName="name"
placeholder="Tower name…"
maxlength="200"
autocomplete="off"
/>
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
<button type="submit" [disabled]="form.invalid">
{{ tower() ? 'Save' : 'Create tower' }}
</button>
@if (tower()) {
<button type="button" (click)="delete.emit()">Delete tower</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
input[type='text'] {
text-align: center;
}
button {
display: block;
}
}
`,
})
export class TowerSettingsComponent implements OnInit {
readonly tower = input<Tower | null>(null);
readonly save = output<TowerSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
currentColor: HslColor = randomDefaultColor();
ngOnInit(): void {
const t = this.tower();
if (t) {
this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color };
}
}
onColorChange(color: HslColor): void {
this.currentColor = color;
}
onSubmit(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
}
}
function randomDefaultColor(): HslColor {
// Pick a hue in [0°, 30°] [200°, 360°] — warm or cool, avoid green.
const warm = Math.random() < 0.5;
const hueDeg = warm ? Math.random() * 30 : 200 + Math.random() * 160;
return { h: hueDeg / 360, s: 0.7, l: 0.55 };
}

View file

@ -0,0 +1,69 @@
<section
class="towers"
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="onTowerDropped($event)"
>
@for (tower of page().towers; track tower.id) {
<lt-tower
cdkDrag
[cdkDragDisabled]="modalOpen()"
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
(cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)"
(saveBlock)="onSaveBlock(tower.id, $event)"
(addBlock)="onAddBlock(tower.id, $event)"
(deleteBlock)="onDeleteBlock(tower.id, $event)"
/>
}
@if (!page().hide_create_tower_button) {
<div class="add-tower-wrapper">
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
</div>
}
</section>
<!-- Trash zone is positioned relative to :host, not .towers — matches legacy. -->
<img
class="trash"
src="assets/trash.svg"
alt="Delete tower"
[class.active]="isDragging()"
(pointerenter)="onTrashEnter()"
(pointerleave)="onTrashLeave()"
/>
@if (showSlider()) {
<div class="double-slider-container" [class.transparent]="isDragging()">
<lt-double-slider
[values]="blockDates()"
[labels]="dateLabels()"
(rangeChange)="onSliderRangeChange($event)"
/>
</div>
}
@if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
</lt-modal>
}
@if (confirmDeleteTowerId(); as towerId) {
<lt-modal (close)="cancelTowerDelete()">
<div class="confirm-delete">
<div class="header">
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
<h2>Delete tower</h2>
</div>
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
<div class="confirm-buttons">
<button type="button" (click)="cancelTowerDelete()">Cancel</button>
<button type="button" class="danger" (click)="confirmTowerDelete()">Delete tower</button>
</div>
</div>
</lt-modal>
}

View file

@ -1,10 +1,11 @@
@import '../../../../styles';
@import '../../../styles';
:host {
display: flex;
flex-direction: column;
height: 100%;
position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding));
@ -32,7 +33,7 @@
}
}
div {
.add-tower-wrapper {
@include center-child();
img.add-tower {
height: 48px;
@ -65,7 +66,7 @@
position: relative;
@for $i from 1 to 6 {
@for $i from 1 to 12 {
& > *:first-child:nth-last-child(#{$i}),
& > *:first-child:nth-last-child(#{$i}) ~ * {
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
@ -78,8 +79,47 @@
}
.double-slider-container {
@media (max-height: $min-height) {
display: none;
width: 100%;
transition: opacity $long-animation-time;
@media (max-height: $min-height) { display: none; }
&.transparent { opacity: 0; pointer-events: none; }
}
.confirm-delete {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
text-align: center;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
p {
strong { font-weight: bold; }
}
.confirm-buttons {
display: flex;
justify-content: center;
gap: var(--large-padding);
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after { background-color: #b53f3f; }
}
}
}

View file

@ -0,0 +1,199 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
inject,
} from '@angular/core';
import { Page } from '../../models';
import { StoreService } from '../../services/store.service';
import { TowerComponent } from '../tower/tower.component';
import { ModalComponent } from '../modal/modal.component';
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
import {
DoubleSliderComponent,
DoubleSliderRange,
} from '../shared/double-slider/double-slider.component';
import { computed } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
import { ModalStateService } from '../../services/modal-state.service';
// ── Relative-time helpers ──────────────────────────────────────────────────
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' });
function formatRelative(ts: number, nowSec: number): string {
const diff = ts - nowSec; // negative = past
const absDiff = Math.abs(diff);
if (absDiff < 45) return rtf.format(Math.round(diff), 'second');
if (absDiff < 60 * 45) return rtf.format(Math.round(diff / 60), 'minute');
if (absDiff < 60 * 60 * 22) return rtf.format(Math.round(diff / 3600), 'hour');
if (absDiff < 86400 * 26) return rtf.format(Math.round(diff / 86400), 'day');
if (absDiff < 86400 * 320) return rtf.format(Math.round(diff / 86400 / 30), 'month');
return rtf.format(Math.round(diff / 86400 / 365), 'year');
}
interface BlockPatch {
tag: string;
description: string;
is_done: boolean;
}
/** Minimum blocks before the date-range slider becomes visible. */
const MIN_BLOCKS_FOR_SLIDER = 2;
@Component({
selector: 'lt-page',
standalone: true,
imports: [
TowerComponent,
ModalComponent,
TowerSettingsComponent,
DoubleSliderComponent,
CdkDropList,
CdkDrag,
],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './page.component.html',
styleUrl: './page.component.scss',
})
export class PageComponent {
readonly page = input.required<Page>();
readonly dragHappening = output<boolean>();
protected readonly store = inject(StoreService);
private readonly modalState = inject(ModalStateService);
/** True while any lt-modal is mounted — used to lock tower drag. */
readonly modalOpen = this.modalState.anyOpen;
readonly showAddTower = signal(false);
readonly isDragging = signal(false);
/** When set, opens a confirmation modal before the dragged tower is deleted. */
readonly confirmDeleteTowerId = signal<string | null>(null);
private draggedTowerId: string | null = null;
private nearTrashcan = false;
readonly confirmDeleteTowerName = computed(() => {
const id = this.confirmDeleteTowerId();
if (!id) return '';
return this.page().towers.find((t) => t.id === id)?.name ?? '';
});
// ── Date-range slider state ────────────────────────────────────────────────
/** Sorted unique `created_at` timestamps (seconds) across all done blocks
* in this page. Empty list when no blocks yet. */
readonly blockDates = computed<number[]>(() => {
const set = new Set<number>();
for (const tower of this.page().towers) {
for (const b of tower.blocks) if (b.is_done) set.add(b.created_at);
}
return [...set].sort((a, b) => a - b);
});
/** Date labels formatted for slider display (deduplicated, insertion order). */
readonly dateLabels = computed<string[]>(() => {
const now = Math.floor(Date.now() / 1000);
const seen = new Set<string>();
const labels: string[] = [];
for (const t of this.blockDates()) {
const lbl = formatRelative(t, now);
if (!seen.has(lbl)) {
seen.add(lbl);
labels.push(lbl);
}
}
return labels;
});
readonly showSlider = computed(() => this.blockDates().length >= MIN_BLOCKS_FOR_SLIDER);
/** Selected date range — `null` = show everything. */
readonly dateRange = signal<{ from: number; to: number } | null>(null);
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
this.dateRange.set({ from: range.from as number, to: range.to as number });
}
// ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void {
this.showAddTower.set(false);
this.store.addTower(this.page().id, result.name, result.base_color);
}
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
this.store.updateTower(this.page().id, towerId, {
name: result.name,
base_color: result.base_color,
});
}
onDeleteTower(towerId: string): void {
this.store.deleteTower(this.page().id, towerId);
}
// ── Block mutations ────────────────────────────────────────────────────────
onAddBlock(towerId: string, result: BlockPatch): void {
this.store.addBlock(
this.page().id,
towerId,
result.tag,
result.description,
result.is_done,
);
}
onSaveBlock(towerId: string, event: { blockId: string; result: BlockPatch }): void {
this.store.updateBlock(this.page().id, towerId, event.blockId, event.result);
}
onDeleteBlock(towerId: string, blockId: string): void {
this.store.deleteBlock(this.page().id, towerId, blockId);
}
// ── Drag-and-drop + trash ──────────────────────────────────────────────────
onTowerDragStart(towerId: string): void {
this.draggedTowerId = towerId;
this.isDragging.set(true);
this.dragHappening.emit(true);
}
onTowerDropped(event: CdkDragDrop<unknown>): void {
this.isDragging.set(false);
this.dragHappening.emit(false);
if (this.nearTrashcan && this.draggedTowerId) {
// Open confirm dialog instead of deleting immediately.
this.confirmDeleteTowerId.set(this.draggedTowerId);
} else if (event.previousIndex !== event.currentIndex) {
this.store.reorderTowers(this.page().id, event.previousIndex, event.currentIndex);
}
this.draggedTowerId = null;
this.nearTrashcan = false;
}
confirmTowerDelete(): void {
const id = this.confirmDeleteTowerId();
if (id) this.store.deleteTower(this.page().id, id);
this.confirmDeleteTowerId.set(null);
}
cancelTowerDelete(): void {
this.confirmDeleteTowerId.set(null);
}
onTrashEnter(): void {
this.nearTrashcan = true;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.add('trash-highlight');
}
onTrashLeave(): void {
this.nearTrashcan = false;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.remove('trash-highlight');
}
}

View file

@ -1,31 +0,0 @@
<section class="towers" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="dropDrag($event)">
<app-tower
*ngFor="let tower of towers"
[tower$]="tower.asObservable()"
[dateRange$]="dateRange"
cdkDrag
(cdkDragStarted)="startDrag(towers.indexOf(tower))"
></app-tower>
<div *ngIf="(page$ | async)?.towers.length < 5 && !(page$ | async)?.userData?.hideCreateTowerButton">
<img src="assets/plus-sign.svg" alt="add tower" class="add-tower" (click)="page.addTower()" />
</div>
</section>
<img
[ngClass]="isDragging ? 'active' : ''"
src="assets/trash.svg"
alt="trashcan"
class="trash"
(pointerenter)="trashEnter()"
(pointerleave)="trashExit()"
(pointerup)="removeTower()"
/>
<div class="double-slider-container" [ngStyle]="{ opacity: isDragging ? '0' : '1' }">
<app-double-slider
*ngIf="dates.length >= MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER"
[values]="dates"
[labels]="dateLabels"
(range)="dateRange.next($event)"
></app-double-slider>
</div>

View file

@ -1,92 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Page } from '../../../model/page';
import { ModalService } from '../../../services/modal.service';
import { Observable } from 'rxjs/internal/Observable';
import { Range } from '../../../interfaces/range';
import { Subject } from 'rxjs/internal/Subject';
import { Tower } from '../../../model/tower';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'app-page',
templateUrl: './page.component.html',
styleUrls: ['./page.component.scss']
})
export class PageComponent implements OnInit {
@Input() page$: Observable<Page>;
private page: Page;
towers: Array<BehaviorSubject<Tower>> = [];
@Output() isDragHappening: EventEmitter<boolean> = new EventEmitter();
readonly MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER = 6;
isDragging = false;
draggedTowerIndex: number;
nearTrashcan = false;
dates: Date[] = [];
dateRange: Subject<Range<Date>> = new Subject<Range<Date>>();
get dateLabels(): string[] {
return this.dates.map(d => d.toLocaleDateString());
}
constructor(private modalService: ModalService) {}
ngOnInit(): void {
this.page$.subscribe(value => {
if (value) {
this.towers = value.towers.map((t, index) => {
if (index < this.towers.length) {
this.towers[index].next(t);
return this.towers[index];
}
return new BehaviorSubject(t);
});
this.page = value;
this.dates = value.towers
.reduce((all, t) => [...t.blocks.map(b => b.created), ...all], [])
.sort((d1, d2) => d1.getTime() - d2.getTime());
}
});
}
dropDrag(event: any) {
this.page.moveTower(event);
this.isDragging = false;
this.isDragHappening.emit(false);
}
startDrag(id: number) {
this.draggedTowerIndex = id;
this.isDragging = true;
this.isDragHappening.emit(true);
}
trashEnter() {
this.nearTrashcan = true;
window.document.querySelector('.cdk-drag-preview').className += ' trash-highlight';
}
trashExit() {
this.nearTrashcan = false;
const elem = window.document.querySelector('.cdk-drag-preview');
elem.className = elem.className
.split(' ')
.slice(0, -1)
.join(' ');
}
async removeTower() {
try {
const tower = this.page.towers[this.draggedTowerIndex];
await this.modalService.showRemoveTower(tower);
this.page.removeTower(tower);
} catch {
// pass
}
}
}

View file

@ -1 +0,0 @@
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="$event.stopPropagation() || handleClick()"></div>

View file

@ -1,15 +0,0 @@
@import '../../../../../../styles';
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6);
div {
position: absolute;
width: 100%;
height: 100%;
@include gravitate();
}
}

View file

@ -1,28 +0,0 @@
import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { ModalService } from '../../../../../services/modal.service';
import { ColoredBlock, Tower } from '../../../../../model/tower';
import { Observable } from 'rxjs/internal/Observable';
@Component({
selector: 'app-block',
templateUrl: './block.component.html',
styleUrls: ['./block.component.scss']
})
export class BlockComponent {
@Input() block: ColoredBlock;
@Input() tower$: Observable<Tower>;
constructor(private modalService: ModalService) {}
async handleClick() {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
startBlock: this.block,
onlyDone: true
});
} catch {
// pass
}
}
}

View file

@ -1,15 +0,0 @@
<div *ngIf="tasks" class="container {{ tasks.length > 0 ? 'show-hover' : '' }}" (click)="$event.stopPropagation()">
<p class="header" (click)="isOpen = !isOpen">
<strong>
{{ tasks.length == 0 ? '' : tasks.length }}
</strong>
<!-- &#8203; is the zero width space -->
{{ tasks.length == 0 ? '&#8203;' : tasks.length == 1 ? 'task' : 'tasks' }}
</p>
<div class="all-task" #allTask [ngStyle]="{ height: (isOpen ? allTask?.scrollHeight : 0) + 'px' }">
<div class="task-container" *ngFor="let task of tasks" [ngStyle]="{ color: task.color | color }">
<div [ngStyle]="{ 'background-color': task.color | color }"></div>
<p (click)="handleClick(task)" [innerText]="task.description ? task.description : 'unknown'"></p>
</div>
</div>
</div>

View file

@ -1,80 +0,0 @@
@import '../../../../../../styles';
:host {
width: 100%;
box-sizing: border-box;
position: relative;
z-index: 100000;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover {
box-shadow: $shadow-border;
}
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
max-height: 30vh;
overflow-y: auto;
.header {
cursor: pointer;
}
p {
font-size: var(--medium-font-size);
}
.all-task {
@include inner-spacing(var(--small-padding));
:first-child {
margin-top: var(--small-padding);
}
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex;
align-items: center;
&:hover {
p {
@media (min-width: $mobile-width) {
color: inherit !important;
}
}
}
div {
flex: 0 0 auto;
margin: 0 calc(var(--small-padding) / 2) 0 0;
@include square(var(--small-padding));
@media (max-width: $mobile-width) {
@include square(calc(var(--small-padding) / 2));
}
}
p {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}

View file

@ -1,55 +0,0 @@
import { ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
import { Block } from '../../../../../model/block';
import { Tower } from '../../../../../model/tower';
import { ModalService } from '../../../../../services/modal.service';
import { CancelService } from '../../../../../services/cancel.service';
import { IColor } from '../../../../../interfaces/color';
import { Observable } from 'rxjs/internal/Observable';
@Component({
selector: 'app-tasks',
templateUrl: './tasks.component.html',
styleUrls: ['./tasks.component.scss']
})
export class TasksComponent {
@Input() tasks: Array<Block & { color: IColor }>;
@Input() tower$: Observable<Tower>;
private _isOpen = false;
@Input() set isOpen(value: boolean) {
if (value) {
this.cancelService.cancelAllExcept(this);
}
this._isOpen = value;
}
get isOpen(): boolean {
return this._isOpen;
}
@ViewChild('allTask') allTask: ElementRef;
constructor(
private modalService: ModalService,
private cancelService: CancelService,
private changeDetection: ChangeDetectorRef
) {
this.cancelService.subscribe(this, () => {
this.isOpen = false;
});
}
async handleClick(block: Block) {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
startBlock: block,
onlyDone: false
});
} catch {
// pass
} finally {
this.changeDetection.markForCheck();
}
}
}

View file

@ -1,31 +0,0 @@
<div class="tower" *ngIf="tower$ | async">
<div class="container">
<div class="tasks-container">
<app-tasks [tasks]="tasks" [tower$]="tower$"></app-tasks>
</div>
<img src="assets/plus-sign.svg" alt="add item" (click)="$event.stopPropagation() || addBlock()" />
<div class="block-container-container">
<div class="block-container" *ngIf="styledBlocks.length > 0">
<app-block
*ngFor="let block of drawableBlocks"
[ngClass]="block.cssClass"
[ngStyle]="block.style"
[block]="block"
[tower$]="tower$"
></app-block>
</div>
</div>
</div>
<label class="hidden">
Card name
<input
type="text"
placeholder="name…"
[(ngModel)]="towerName"
[ngStyle]="{ color: (tower$ | async)?.baseColor | color }"
/>
</label>
</div>

View file

@ -1,152 +0,0 @@
@import '../../../../../styles';
:host {
cursor: pointer;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
&.cdk-drag-placeholder {
opacity: 0;
}
&:hover {
@media (min-width: $mobile-width) {
div.container {
box-shadow: $shadow;
}
}
}
&.cdk-drag-preview {
div.container {
@media (max-width: $mobile-width) {
@keyframes shadow {
from {
box-shadow: none;
}
to {
box-shadow: $shadow;
}
}
animation: shadow $long-animation-time forwards;
}
}
}
&.trash-highlight {
.container {
transform: scale(0.75);
position: relative;
:before {
opacity: 0.5 !important;
}
}
input {
display: none;
}
}
.tower {
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%;
height: 100%;
@include inner-spacing(var(--small-padding));
.container {
display: flex;
flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before {
content: '';
pointer-events: none;
position: absolute;
z-index: 2;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: red;
opacity: 0;
border-radius: var(--border-radius);
transition: opacity $short-animation-time;
}
img {
position: relative;
z-index: 2;
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
* {
transform: translateY(500%);
}
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
}
}
}
}
input[type='text'] {
font-size: var(--small-font-size);
text-align: center;
@media (min-width: $mobile-width) {
width: 50%;
}
}
}
}

View file

@ -1,123 +0,0 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { ColoredBlock, Tower } from '../../../../model/tower';
import { ModalService } from '../../../../services/modal.service';
import { Observable } from 'rxjs/internal/Observable';
import { Range } from '../../../../interfaces/range';
import { top } from '../../../../utils/top';
import { CancelService } from '../../../../services/cancel.service';
type StyledBlock = ColoredBlock & { style: { [p: string]: string }; shouldDraw: boolean; cssClass: string };
@Component({
selector: 'app-tower',
templateUrl: './tower.component.html',
styleUrls: ['./tower.component.scss']
})
export class TowerComponent implements OnInit {
@Input() dateRange$: Observable<Range<Date>>;
@Input() tower$: Observable<Tower>;
private dateRange: Range<Date>;
private tower: Tower;
get towerName(): string {
return this.tower ? this.tower.name : 'Loading…';
}
set towerName(value: string) {
this.tower.changeName(value);
}
tasks: Array<ColoredBlock>;
styledBlocks: Array<StyledBlock> = [];
get drawableBlocks(): Array<StyledBlock> {
return this.styledBlocks.filter(b => b.shouldDraw);
}
public constructor(private modalService: ModalService, private changeDetection: ChangeDetectorRef) {}
ngOnInit() {
this.tower$.subscribe(value => {
// console.log(this.tower, value);
if (value) {
this.styledBlocks = value.coloredBlocks
.filter(b => b.isDone)
.map(b => {
const classedBlock = b as StyledBlock;
classedBlock.shouldDraw = true;
classedBlock.style = { transform: 'translateY(0)', opacity: '1' };
classedBlock.cssClass = '';
return classedBlock;
});
if (this.tower && this.tower.latestVersion === value) {
const difference = this.tower.blocks.map((b, index) => {
return b === value.blocks[index];
});
if (
(difference.every(i => i) &&
this.tower.blocks.length + 1 === value.blocks.length &&
top(value.blocks).isDone) ||
(this.tower.blocks.length === value.blocks.length &&
this.tower.blocks.filter(b => b.isDone).length + 1 === value.blocks.filter(b => b.isDone).length)
) {
const lastBlock = top(this.styledBlocks);
if (lastBlock) {
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
setTimeout(() => {
this.makeBlockDescend(lastBlock);
this.changeDetection.markForCheck();
}, 0);
}
}
}
this.tasks = value.coloredBlocks.filter(block => !block.isDone);
this.tower = value;
this.changeDetection.markForCheck();
}
});
this.dateRange$.subscribe(dateRange => {
this.initData(dateRange);
this.dateRange = dateRange;
});
}
makeBlockDescend(block: StyledBlock) {
block.cssClass = 'descend';
block.style = { transform: 'translateY(0)', opacity: '1' };
}
makeBlockAscend(block: StyledBlock) {
block.cssClass = 'ascend';
block.style = { transform: 'translateY(500%)', opacity: '0' };
}
initData(newDateRange: Range<Date>) {
for (const block of this.styledBlocks) {
block.shouldDraw = newDateRange.from <= block.created;
if (newDateRange.to < block.created) {
this.makeBlockAscend(block);
}
if (block.shouldDraw && block.created <= newDateRange.to) {
this.makeBlockDescend(block);
}
}
}
public async addBlock() {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
onlyDone: true
});
} catch {
// pass
}
}
}

View file

@ -1,22 +1,44 @@
<div class="select-add-container">
<!-- wrapper for easier styling -->
<app-select-add
[options]="pageNames"
[default]="(selectedPage$ | async)?.name"
(value)="selectPage($event)"
(optionChange)="changeName($event)"
[placeholder]="'Add a new page…'"
[editable]="true"
></app-select-add>
<lt-select-add
[options]="pageNames()"
[selectedIndex]="selectedPageIndex()"
placeholder="Add a new page…"
(selectionChange)="onSelectPage($event)"
(add)="onAddPage($event)"
/>
</div>
<!-- wrapper for easier styling -->
<div class="page-container">
<!-- wrapper for easier styling -->
<app-page [page$]="selectedPage$" (isDragHappening)="isDragHappening = $event"></app-page>
@if (selectedPage(); as page) {
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
} @else {
<p>Add a new page to get started!</p>
}
</div>
<!-- wrapper for easier styling -->
<button [ngClass]="isDragHappening ? 'transparent' : ''" (click)="$event.stopPropagation(); openSettings()">
<button [class.transparent]="dragHappening()" (click)="showSettings.set(true)">
Settings
</button>
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-settings
[token]="store.token()"
[page]="selectedPage()"
(close)="showSettings.set(false)"
(updatePage)="onUpdatePage($event)"
(deletePage)="onRemovePage()"
(switchAccount)="onSwitchAccount($event)"
/>
</lt-modal>
}
@if (showWelcome()) {
<lt-modal (close)="showWelcome.set(false)">
<lt-welcome
(close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)"
(loadExample)="onLoadExample()"
/>
</lt-modal>
}

View file

@ -13,6 +13,8 @@
width: 250px;
margin-left: auto;
margin-right: auto;
position: relative;
z-index: 1000;
}
.page-container {

View file

@ -1,113 +1,108 @@
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Page } from '../../model/page';
import { DataService } from '../../services/data.service';
import { ModalService } from '../../services/modal.service';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Observable } from 'rxjs/internal/Observable';
import { Data } from '../../model/data';
import { of } from 'rxjs/internal/observable/of';
const USER_DATA_KEY = 'life-towers.user-data.v.2';
import {
Component,
ChangeDetectionStrategy,
inject,
signal,
computed,
effect,
} from '@angular/core';
import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component';
import { ModalComponent } from '../modal/modal.component';
import { SettingsComponent, UpdatePagePayload } from '../modal/settings.component';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { WelcomeComponent } from '../welcome/welcome.component';
import { Page } from '../../models';
@Component({
selector: 'app-pages',
selector: 'lt-pages',
standalone: true,
imports: [PageComponent, ModalComponent, SettingsComponent, SelectAddComponent, WelcomeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './pages.component.html',
styleUrls: ['./pages.component.scss']
styleUrl: './pages.component.scss',
})
export class PagesComponent implements OnInit {
@ViewChild('top') top: ElementRef;
@ViewChild('page') page: ElementRef;
@ViewChild('bottom') bottom: ElementRef;
export class PagesComponent {
protected readonly store = inject(StoreService);
data: Data;
pages: Array<Page>;
isDragHappening = false;
/** ID of currently selected page within store.pages(). */
private readonly selectedPageId = signal<string | null>(null);
get pageNames() {
if (this.pages) {
return this.pages.map(p => p.name);
}
return [];
}
readonly showSettings = signal(false);
readonly dragHappening = signal(false);
readonly showWelcome = signal(false);
selectedPageName: string;
private readonly _selectedPage: BehaviorSubject<Page> = new BehaviorSubject(null);
readonly selectedPage$: Observable<Page> = this._selectedPage.asObservable();
constructor(
public dataService: DataService,
private modalService: ModalService,
private changeDetection: ChangeDetectorRef
) {
const userData = JSON.parse(window.localStorage.getItem(USER_DATA_KEY));
if (userData !== null) {
this.selectedPageName = userData.selectedPage;
}
}
ngOnInit() {
this.dataService.children$.subscribe(dataContainer => {
if (dataContainer && dataContainer.length > 0) {
this.data = dataContainer[0];
const pages = this.data.pages;
if (this.pages && !pages.includes(this._selectedPage.getValue().latestVersion)) {
this.selectedPageName = null;
}
this.pages = pages;
this.selectPage(this.selectedPageName);
constructor() {
effect(() => {
if (!this.store.loading() && this.store.pages().length === 0) {
this.showWelcome.set(true);
} else if (this.store.pages().length > 0) {
this.showWelcome.set(false);
}
});
}
changeName({ from, to }: { from: string; to: string }) {
const page = this.pages.find(p => p.name === from);
onLoadExample(): void {
this.store.loadExample();
this.showWelcome.set(false);
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => {
const pages = this.store.pages();
if (pages.length === 0) return null;
const id = this.selectedPageId();
if (id) {
const found = pages.find((p) => p.id === id);
if (found) return found;
}
// Default to first page.
return pages[0] ?? null;
});
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
readonly selectedPageIndex = computed(() => {
const page = this.selectedPage();
if (!page) return -1;
return this.store.pages().findIndex((p) => p.id === page.id);
});
onSelectPage(index: number): void {
const pages = this.store.pages();
const page = pages[index];
if (page) {
if (from === this.selectedPageName) {
this.selectedPageName = to;
}
page.changeName(to);
this.selectedPageId.set(page.id);
}
}
selectPage(name: string) {
if (!name) {
if (this.pages && this.pages.length > 0) {
name = this.pages[0].name;
}
onAddPage(name: string): void {
this.store.addPage(name);
// Select the newly added page.
const pages = this.store.pages();
const newPage = pages[pages.length - 1];
if (newPage) {
this.selectedPageId.set(newPage.id);
}
this.selectedPageName = name;
window.localStorage.setItem(
USER_DATA_KEY,
JSON.stringify({
selectedPage: name
})
);
if (this.pages && name) {
if (!this.pageNames.includes(name)) {
this.data.addPage(name);
}
const index = this.pageNames.indexOf(name);
this._selectedPage.next(this.pages[index]);
return;
}
this._selectedPage.next(null);
}
async openSettings() {
try {
await this.modalService.showSettings({
page$: this.selectedPage$,
data$: of(this.data)
});
} catch {
// pass
} finally {
this.changeDetection.markForCheck();
onUpdatePage(payload: UpdatePagePayload): void {
const page = this.selectedPage();
if (page) {
this.store.updatePage(page.id, payload);
}
}
onRemovePage(): void {
const page = this.selectedPage();
if (!page) return;
this.store.deletePage(page.id);
this.selectedPageId.set(null);
this.showSettings.set(false);
}
onSwitchAccount(token: string): void {
this.store.switchToken(token);
}
}

View file

@ -0,0 +1,187 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from '@angular/core';
import { HslColor } from '../../../models';
import { toCss } from '../../../utils/color';
// 12 hand-picked hues. Rationale:
// Warm cluster (045°): coral/red, orange-red, orange, amber — vivid warm tones
// Skipped 60180° (yellows + greens) — most read as muddy next to the rose UI accent
// Cool cluster (195260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
// Purple-rose cluster (280355°): violet, magenta, rose-pink, near-red — complements the accent
const PRESETS: number[] = [0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355];
const FIXED_S = 0.7;
const FIXED_L = 0.55;
@Component({
selector: 'lt-color-picker',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="picker">
<div class="swatches" role="group" aria-label="Preset colors">
@for (h of presetHues; track h) {
<button
type="button"
class="swatch"
[class.active]="isActiveHue(h)"
[style.background-color]="hueToCss(h)"
[attr.aria-label]="'Pick hue ' + h + ' degrees'"
[attr.aria-pressed]="isActiveHue(h)"
(click)="pickHue(h)"
></button>
}
</div>
<div class="hue-slider">
<input
type="range"
min="0"
max="360"
step="1"
[value]="hueDeg()"
(input)="onSlider($any($event.target).value)"
[style.--thumb-color]="toCss(color())"
aria-label="Hue"
/>
</div>
<div class="preview" [style.background-color]="toCss(color())" aria-hidden="true"></div>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
display: block;
padding: var(--medium-padding);
@include card();
box-shadow: $shadow-border;
}
.picker {
display: flex;
flex-direction: column;
gap: var(--medium-padding);
width: 100%;
}
.swatches {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 6px;
.swatch {
all: unset;
cursor: pointer;
aspect-ratio: 1;
border-radius: 4px;
box-shadow: $shadow-border;
transition: transform $short-animation-time, box-shadow $long-animation-time;
&:hover,
&:focus-visible {
box-shadow: $shadow;
transform: scale(1.1);
}
&.active {
box-shadow: $shadow;
transform: scale(1.15);
outline: 2px solid $light-color;
outline-offset: 1px;
}
}
}
.hue-slider {
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
border-radius: 1000px;
background: linear-gradient(
to right,
hsl(0, 70%, 55%),
hsl(60, 70%, 55%),
hsl(120, 70%, 55%),
hsl(180, 70%, 55%),
hsl(240, 70%, 55%),
hsl(300, 70%, 55%),
hsl(360, 70%, 55%)
);
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 24px;
width: 24px;
border-radius: 1000px;
background-color: var(--thumb-color, #{$light-color});
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
cursor: grab;
&:active {
cursor: grabbing;
}
}
&::-moz-range-thumb {
height: 24px;
width: 24px;
border-radius: 1000px;
background-color: var(--thumb-color, white);
border: 2px solid white;
box-shadow: $shadow;
cursor: grab;
&:active {
cursor: grabbing;
}
}
}
}
.preview {
height: 40px;
border-radius: var(--border-radius);
box-shadow: $shadow-border;
}
`,
})
export class ColorPickerComponent {
readonly color = input.required<HslColor>();
readonly colorChange = output<HslColor>();
readonly presetHues = PRESETS;
readonly hueDeg = computed(() => Math.round(this.color().h * 360));
isActiveHue(h: number): boolean {
return Math.abs(this.hueDeg() - h) < 8;
}
hueToCss(h: number): string {
return `hsl(${h}, 70%, 55%)`;
}
toCss(c: HslColor): string {
return toCss(c);
}
pickHue(h: number): void {
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
}
onSlider(value: string): void {
this.colorChange.emit({ h: Number(value) / 360, s: FIXED_S, l: FIXED_L });
}
}

View file

@ -1,13 +0,0 @@
<div class="container">
<label for="date-selector-1">date selector 1</label>
<label for="date-selector-2">date selector 2</label>
<input id="date-selector-1" type="range" min="0" [max]="MAX - 1" [(ngModel)]="oneValue" />
<input id="date-selector-2" type="range" min="0" [max]="MAX - 1" [(ngModel)]="otherValue" />
<div class="value-container">
<span
*ngFor="let i of drawnLabelsIndices"
[innerHTML]="drawnLabels[i]"
[ngStyle]="{ transform: getOffset(i) }"
></span>
</div>
</div>

View file

@ -1,77 +0,0 @@
@import '../../../../styles';
$height: 70px;
$width: 300px;
$slider-size: 40px;
.container {
width: $width;
height: $height;
position: relative;
margin: $slider-size / 2 auto 0 auto;
label {
display: none;
}
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
transform-origin: center center;
transform: translateY(-$slider-size / 2 + $line-height / 2);
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(-$slider-size / 2 + $line-height / 2) scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer {
border: 0;
}
}
.value-container {
@include small-text();
display: flex;
justify-content: space-evenly;
span {
display: block;
margin-top: 10px;
}
}
}

View file

@ -1,107 +1,233 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { range } from '../../../utils/range';
import { Range } from '../../../interfaces/range';
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
export interface DoubleSliderRange<T> {
from: T;
to: T;
}
/**
* Two-thumb range slider legacy "double-slider".
* Hands an indexed range over an arbitrary values array; emits the
* underlying values on each change. Labels magnetically lift as a thumb
* approaches them (rotated -45°), per the legacy.
*/
@Component({
selector: 'app-double-slider',
templateUrl: './double-slider.component.html',
styleUrls: ['./double-slider.component.scss']
selector: 'lt-double-slider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="container">
<label for="ds-1">From</label>
<label for="ds-2">To</label>
<input
id="ds-1"
type="range"
min="0"
[max]="MAX - 1"
[value]="oneValue()"
(input)="oneValue.set(+$any($event.target).value)"
/>
<input
id="ds-2"
type="range"
min="0"
[max]="MAX - 1"
[value]="otherValue()"
(input)="otherValue.set(+$any($event.target).value)"
/>
<div class="value-container">
@for (i of drawnIndices(); track i) {
<span [style.transform]="getOffset(i)">{{ drawnLabels()[i] }}</span>
}
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$line-height: 2px;
$height: 90px;
$slider-size: 40px;
.container {
width: 100%;
max-width: 800px;
height: $height;
position: relative;
margin: calc(#{$slider-size} / 2) auto 0 auto;
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
appearance: none;
outline: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
box-shadow: $shadow-border;
transform-origin: center center;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2));
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2))
scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
border: none;
box-shadow: $shadow-border;
cursor: pointer;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-range-track {
-moz-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer { border: 0; }
}
.value-container {
font-family: $normal-font;
color: $text-color;
font-size: var(--medium-font-size);
display: flex;
justify-content: space-evenly;
margin-top: calc(#{$slider-size} + 8px);
span {
display: block;
margin-top: 14px;
transform-origin: center bottom;
transition: transform $long-animation-time;
white-space: nowrap;
}
}
}
`,
})
export class DoubleSliderComponent {
@Input() labels: string[];
/** Ordered list of underlying values (e.g. dates). */
readonly values = input.required<unknown[]>();
/** Display labels for evenly-spaced ticks (≤ values.length). */
readonly labels = input.required<string[]>();
@Input() set values(values: any[]) {
if (values.length === 0) {
return;
}
this._values = values;
this.calculateLabels();
if (this._oneValue > this._otherValue) {
this._oneValue = this.MAX - 1;
} else {
this._otherValue = this.MAX - 1;
}
this.emitValue();
}
get values(): any[] {
return this._values;
}
get oneValue(): number {
return this._oneValue;
}
set oneValue(value: number) {
this._oneValue = value;
this.emitValue();
}
get otherValue(): number {
return this._otherValue;
}
set otherValue(value: number) {
this._otherValue = value;
this.emitValue();
}
private _values: any[];
@Output() range: EventEmitter<Range<any>> = new EventEmitter();
drawnLabels: string[];
readonly rangeChange = output<DoubleSliderRange<unknown>>();
readonly MAX = 100;
private _oneValue = 0;
readonly oneValue = signal(0);
readonly otherValue = signal(this.MAX - 1);
private _otherValue: number = this.MAX - 1;
private prevValuesLength = 0;
drawnLabelsIndices: Iterable<number>;
readonly drawnLabels = computed(() => {
const labels = this.labels();
const count = Math.min(labels.length, 6);
if (count === 0) return [] as string[];
const jump = Math.max(1, Math.round(labels.length / count));
return labels.filter((_, i) => i % jump === 0);
});
private calculateLabels() {
const labelCount = 6;
const jumpLength = Math.round(this.labels.length / labelCount);
this.drawnLabels = this.labels.filter((_, index) => index % jumpLength === 0);
this.drawnLabelsIndices = range({ max: this.drawnLabels.length });
readonly drawnIndices = computed(() =>
Array.from({ length: this.drawnLabels().length }, (_, i) => i),
);
constructor() {
// Re-emit the value range whenever the slider thumbs or values change.
effect(() => {
const a = this.oneValue();
const b = this.otherValue();
const vs = this.values();
if (vs.length === 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
untracked(() => {
this.rangeChange.emit({
from: vs[this.indexFromValue(lo)],
to: vs[this.indexFromValue(hi)],
});
});
});
// Snap the higher thumb to MAX - 1 when a new entry is appended.
effect(() => {
const len = this.values().length;
untracked(() => {
if (len > this.prevValuesLength) {
const a = this.oneValue();
const b = this.otherValue();
if (a > b) this.oneValue.set(this.MAX - 1);
else this.otherValue.set(this.MAX - 1);
}
this.prevValuesLength = len;
});
});
}
private indexFromValue(value: number): number {
return Math.floor((value / this.MAX) * this.values.length);
return Math.min(
this.values().length - 1,
Math.floor((value / this.MAX) * this.values().length),
);
}
/**
* Magnetic label position: returns a CSS `transform` that lifts the label
* upward and rotates -45° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / this.drawnLabels.length;
const slider1Index = this.oneValue / this.MAX - 0.1;
const slider2Index = this.otherValue / this.MAX - 0.1;
const dist = (a, b) => Math.abs(a - b);
const labelSliderDistance = Math.min(dist(labelIndex, slider1Index), dist(labelIndex, slider2Index));
const labelIndex = index / Math.max(1, this.drawnLabels().length);
const a = this.oneValue() / this.MAX - 0.1;
const b = this.otherValue() / this.MAX - 0.1;
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
const ACTIVE_ZONE = 0.2;
const BASE_TRANSFORM = 'translateX(-50%) rotate(-45deg) translateY(100%)';
if (labelSliderDistance > ACTIVE_ZONE) {
return BASE_TRANSFORM;
}
return `translateY(${Math.pow((ACTIVE_ZONE - labelSliderDistance) / ACTIVE_ZONE, 1) * 30}px) ${BASE_TRANSFORM}`;
}
private emitValue() {
this.range.emit({
from:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.oneValue)]
: this.values[this.indexFromValue(this.otherValue)],
to:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.otherValue)]
: this.values[this.indexFromValue(this.oneValue)]
});
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
if (dist > ACTIVE_ZONE) return base;
const lift = ((ACTIVE_ZONE - dist) / ACTIVE_ZONE) * 36;
return `translateY(${lift}px) ${base}`;
}
}

View file

@ -0,0 +1,29 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
export type IconName = 'arrow' | 'pen' | 'plus-sign' | 'trash' | 'x-sign';
@Component({
selector: 'lt-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<img
[src]="'assets/' + name() + '.svg'"
[alt]="name()"
[style.width]="size()"
[style.height]="size()"
aria-hidden="true"
/>
`,
styles: `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
`,
})
export class IconComponent {
readonly name = input.required<IconName>();
readonly size = input<string>('1.25rem');
}

View file

@ -1,45 +0,0 @@
<div class="select-add {{ onlyShadowBorder ? 'shadow-border' : '' }}" (click)="$event.stopPropagation()">
<div #top class="top" (click)="!editMode && toggle()">
<p [innerHTML]="selected ? selected : placeholder" *ngIf="!editMode || !selected; else editableSelected"></p>
<ng-template #editableSelected>
<input type="text" [value]="selected" (change)="changeOption(selected, $event)" />
</ng-template>
<img src="assets/arrow.svg" (click)="onArrowClick($event)" [className]="isOpen ? 'upside-down' : ''" alt="arrow" />
</div>
<div class="bottom-container">
<div #bottom class="bottom {{ isOpen ? 'open' : '' }}">
<ng-container *ngIf="!editMode; else editableOthers">
<p *ngFor="let option of otherOptions" [innerHTML]="option" (click)="select(option)"></p>
</ng-container>
<ng-template #editableOthers>
<input
type="text"
*ngFor="let option of otherOptions"
[value]="option"
(change)="changeOption(option, $event)"
/>
</ng-template>
<input
type="text"
*ngIf="options.length <= maxItemCount"
[placeholder]="newValuePlaceholder"
[(ngModel)]="newOption"
(keyup)="handleKeys($event)"
/>
<div class="buttons">
<button *ngIf="options.length <= maxItemCount" (click)="addNewOption()" [disabled]="!newOption">Add</button>
<div *ngIf="editable" class="edit {{ editMode ? 'active' : '' }}" (click)="editMode = !editMode">
<img src="assets/pen.svg" alt="edit" />
</div>
</div>
</div>
</div>
<div
class="background {{ isOpen || alwaysDropShadow ? 'active' : '' }}"
[ngStyle]="{ height: backgroundHeight }"
></div>
</div>

View file

@ -1,189 +0,0 @@
@import '../../../../styles';
$inner-padding: var(--medium-padding);
.select-add {
width: 100%;
position: relative;
.top,
.bottom {
padding: $inner-padding;
z-index: 4;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
p,
input[type='text'] {
display: inline-block;
@include sub-title-text();
}
img {
@include square(16px);
transition: transform $long-animation-time;
&.upside-down {
transform: rotate(-180deg);
}
}
}
.bottom-container {
width: 100%;
height: 300px;
position: absolute;
overflow-y: hidden;
pointer-events: none;
.bottom {
position: absolute;
width: 100%;
pointer-events: all;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding-top: 0;
@include inner-spacing($inner-padding);
transform: translateY(-100%);
visibility: hidden;
&.open {
visibility: visible;
transform: none;
}
transition: transform $long-animation-time;
p {
@include sub-title-text();
display: inline-block;
text-align: left;
cursor: pointer;
}
.buttons {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
width: 100%;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
}
.edit {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0;
opacity: 0.25;
cursor: pointer;
img {
@include square(16px);
}
transition: opacity $short-animation-time;
&:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * #{$line-height});
left: 0;
height: $line-height;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover {
opacity: 0.5;
}
&:hover {
&:before {
width: 100% !important;
}
}
}
&.active {
&:before {
width: 100% !important;
}
}
&.active {
opacity: 1;
}
}
}
}
.edit {
}
}
.background {
position: absolute;
top: 0;
height: 100%;
width: 100%;
@include card();
z-index: 3;
transition: box-shadow $long-animation-time, height $long-animation-time;
&.active {
box-shadow: $shadow;
}
}
&:hover {
@media (min-width: $mobile-width) {
.background {
box-shadow: $shadow;
}
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
}
}
&.shadow-border:hover {
.background {
box-shadow: $shadow-border;
}
}
}

View file

@ -1,107 +1,357 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
import { CancelService } from '../../../services/cancel.service';
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
OnChanges,
SimpleChanges,
} from '@angular/core';
@Component({
selector: 'app-select-add',
templateUrl: './select-add.component.html',
styleUrls: ['./select-add.component.scss']
selector: 'lt-select-add',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.shadow-border]="onlyShadowBorder()"
[class.always-shadow]="alwaysDropShadow()"
>
<div class="background" [class.active]="open()"></div>
<div class="top" (click)="open.update(v => !v)">
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
<div class="bottom-container">
<div class="bottom" [class.open]="open()">
@for (item of resolvedItems(); track item) {
@if (editing()) {
<input
type="text"
[value]="item"
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
<p (click)="onSelectItem(item)">{{ item }}</p>
}
}
<div class="add-row">
<input
type="text"
#addInput
placeholder="Add a value…"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button>
@if (editable()) {
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
<img src="assets/pen.svg" alt="Edit" />
</button>
}
</div>
</div>
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$inner-padding: var(--medium-padding);
:host {
display: block;
width: 100%;
}
.container {
width: 100%;
position: relative;
.top,
.bottom {
padding: $inner-padding;
z-index: 4;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
p {
display: inline-block;
@include sub-title-text();
}
img.arrow {
@include square(16px);
transition: transform $long-animation-time;
&.upside-down {
transform: rotate(-180deg);
}
}
}
.bottom-container {
width: 100%;
height: 300px;
position: absolute;
overflow-y: hidden;
pointer-events: none;
.bottom {
position: absolute;
width: 100%;
pointer-events: all;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: $inner-padding;
padding-top: 0;
@include inner-spacing($inner-padding);
// Default (closed) state — also the target of the close transition.
background-color: transparent;
box-shadow: none;
transform: translateY(-100%);
visibility: hidden;
// Delay the visibility change until after the slide animation finishes
// so the panel stays visible while it animates closed.
transition:
transform $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s $long-animation-time;
&.open {
visibility: visible;
transform: none;
background-color: $light-color;
box-shadow: $shadow;
// Show shadow on left/right/bottom only; clip the top edge so the
// shadow doesn't bleed over the seam where .bottom meets .top.
clip-path: inset(0 -6px -6px -6px);
// On open, visibility flips immediately (no delay); transform +
// colors + shadow animate over $long-animation-time.
transition:
transform $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s 0s;
}
p {
@include sub-title-text();
display: inline-block;
text-align: left;
cursor: pointer;
}
input[type='text'] {
@include sub-title-text();
width: 100%;
}
.add-row {
height: 32px;
@media (max-width: $mobile-width) { height: 24px; }
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: var(--small-padding);
input[type='text'] {
flex: 1;
}
button {
margin: 0;
position: static;
&.pen {
opacity: 0.25;
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
border: none;
background: transparent;
position: relative;
img {
@include square(16px);
}
&:before {
content: '';
display: block;
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover { opacity: 0.5; }
&:hover:before { width: 100% !important; }
}
&.active {
opacity: 1;
&:before { width: 100% !important; }
}
transition: opacity $short-animation-time;
}
}
}
}
}
.background {
position: absolute;
top: 0;
height: 100%;
width: 100%;
@include card();
z-index: 3;
transition:
box-shadow $long-animation-time,
height $long-animation-time,
border-radius $long-animation-time;
&.active {
box-shadow: $shadow;
// Show shadow on top/left/right only; clip the bottom edge so the
// shadow doesn't bleed over the seam where .top meets .bottom.
clip-path: inset(-6px -6px 0 -6px);
}
}
.top {
transition: border-radius $long-animation-time;
}
// When the drawer is open, square the BOTTOM corners on both the
// background card and the top chip so they merge into one continuous
// surface. (Animated via the border-radius transitions above.)
&:has(.bottom.open) {
.top,
.background {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
}
&:hover {
@media (min-width: $mobile-width) {
.background { box-shadow: $shadow; }
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
clip-path: inset(-6px -6px 0 -6px);
}
}
&.shadow-border:hover {
.background { box-shadow: $shadow-border; }
}
&.always-shadow {
.background { box-shadow: $shadow; }
// When open, clip the bottom so the always-on shadow doesn't bleed
// over the seam; restore full shadow when closed.
&:has(.bottom.open) .background {
clip-path: inset(-6px -6px 0 -6px);
}
}
}
`,
})
export class SelectAddComponent {
@Input() placeholder = 'Add a new value…';
@Input() newValuePlaceholder = 'Add a value…';
@Input() maxItemCount = 7;
@Input() options: string[];
@Input() alwaysDropShadow = false;
@Input() onlyShadowBorder = false;
@Input() editable = false;
export class SelectAddComponent implements OnChanges {
// ── New API (spec) ─────────────────────────────────────────────────────────
/** List of string options */
readonly items = input<string[]>([]);
/** Currently selected string value */
readonly selected = input<string | null>(null);
readonly editable = input<boolean>(false);
readonly placeholder = input<string>('Select…');
readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false);
@Input() set default(value: string) {
this.selected = value;
// ── Legacy compat API (used by pages.component.html until Agent B updates) ─
/** @deprecated Use items instead */
readonly options = input<string[]>([]);
/** @deprecated Use selected (string) instead */
readonly selectedIndex = input<number>(-1);
// ── Outputs — new API ──────────────────────────────────────────────────────
readonly select = output<string>();
readonly add = output<string>();
readonly rename = output<{ old: string; new: string }>();
readonly remove = output<string>();
// ── Legacy compat outputs ──────────────────────────────────────────────────
/** @deprecated Use select instead */
readonly selectionChange = output<number>();
// ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false);
readonly editing = signal(false);
// Resolved values that merge old + new API
protected resolvedItems(): string[] {
const newItems = this.items();
const oldOptions = this.options();
return newItems.length ? newItems : oldOptions;
}
backgroundHeight: string;
private _editMode = false;
set editMode(value: boolean) {
this._editMode = value;
this.backgroundHeight = this.getBackgroundHeight();
protected resolvedSelected(): string | null {
// New API: string
const s = this.selected();
if (s != null) return s;
// Legacy API: index into options
const idx = this.selectedIndex();
const opts = this.resolvedItems();
if (idx >= 0 && idx < opts.length) return opts[idx];
return null;
}
get editMode(): boolean {
return this._editMode;
ngOnChanges(_changes: SimpleChanges): void {
// Nothing to do — signals handle reactivity
}
@Output() value: EventEmitter<string> = new EventEmitter();
@Output() optionChange: EventEmitter<{ from: string; to: string }> = new EventEmitter();
@ViewChild('top') top: ElementRef;
@ViewChild('bottom') bottom: ElementRef;
selected: string;
newOption: string;
isOpen = false;
constructor(private cancelService: CancelService, private changeDetection: ChangeDetectorRef) {
this.cancelService.subscribe(this, () => {
this.isOpen = false;
this.editMode = false;
this.changeDetection.markForCheck();
});
onSelectItem(item: string): void {
this.select.emit(item);
// Legacy compat: also emit the index
const idx = this.resolvedItems().indexOf(item);
if (idx >= 0) this.selectionChange.emit(idx);
this.open.set(false);
this.editing.set(false);
}
changeOption(from: string, event) {
// console.log(event);
this.optionChange.emit({
from,
to: event.target.value
});
onAdd(value: string): void {
const v = value.trim();
if (!v) return;
this.add.emit(v);
this.open.set(false);
}
get otherOptions(): string[] {
return this.options.filter(a => a !== this.selected);
}
handleKeys(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.addNewOption();
onRename(oldValue: string, newValue: string): void {
const n = newValue.trim();
if (n && n !== oldValue) {
this.rename.emit({ old: oldValue, new: n });
}
}
addNewOption() {
if (this.newOption) {
this.select(this.newOption);
this.newOption = '';
}
}
select(option: string) {
this.selected = option;
this.value.emit(this.selected);
this.toggle();
}
toggle() {
this.isOpen = !this.isOpen;
if (!this.isOpen) {
this.editMode = false;
}
this.backgroundHeight = this.getBackgroundHeight();
}
onArrowClick(event) {
if (this.editMode) {
this.toggle();
event.stopPropagation();
}
}
private getBackgroundHeight(): string {
if (this.isOpen && this.top && this.bottom) {
const topHeight = this.top.nativeElement.clientHeight;
const bottomHeight = this.bottom.nativeElement.clientHeight;
// console.log(topHeight, bottomHeight);
return `${topHeight + bottomHeight}px`;
}
return `100%`;
}
}

View file

@ -1,7 +0,0 @@
<span [className]="!on ? 'active' : ''" (click)="on = false" [innerText]="beforeText"></span>
<label>
<input type="checkbox" [(ngModel)]="on" [className]="on ? 'on' : ''" />
</label>
<span [className]="on ? 'active' : ''" (click)="on = true" [innerText]="afterText"></span>

View file

@ -1,75 +0,0 @@
@import '../../../../styles';
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
span {
@include medium-text();
max-width: 3 * $size;
cursor: pointer;
&.active {
font-weight: bold;
}
&:first-of-type {
text-align: right;
}
&:last-of-type {
text-align: left;
}
}
label {
display: block;
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
width: 2 * $size;
height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: '';
position: absolute;
display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after {
left: $size;
}
}
input[type='checkbox'] {
@media (min-width: $mobile-width) {
&:hover:after {
box-shadow: $shadow;
transform: translateX(2px);
}
&.on:hover:after {
transform: translateX(-2px);
}
}
}
}
}

View file

@ -1,27 +1,105 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
input,
model,
} from '@angular/core';
@Component({
selector: 'app-toggle',
templateUrl: './toggle.component.html',
styleUrls: ['./toggle.component.scss']
selector: 'lt-toggle',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toggle">
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
<label>
<input
type="checkbox"
[class.on]="checked()"
[checked]="checked()"
(change)="set(!checked())"
/>
</label>
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
.toggle {
display: contents;
}
span {
@include medium-text();
// Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label.
flex: 0 0 auto;
width: 4 * $size;
box-sizing: border-box;
padding: 0 var(--small-padding);
line-height: 1.3;
cursor: pointer;
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
}
label {
display: block;
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
width: 2 * $size;
height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: '';
position: absolute;
display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after { left: $size; }
}
input[type='checkbox'] {
@media (min-width: $mobile-width) {
&:hover:after {
box-shadow: $shadow;
transform: translateX(2px);
}
&.on:hover:after {
transform: translateX(-2px);
}
}
}
}
}
`,
})
export class ToggleComponent {
@Input() beforeText: string;
@Input() afterText: string;
readonly checked = model<boolean>(false);
readonly offLabel = input<string>('No');
readonly onLabel = input<string>('Yes');
@Output() value: EventEmitter<boolean> = new EventEmitter();
@Input() set default(value: boolean) {
this._on = value;
}
private _on = false;
set on(value: boolean) {
this._on = value;
this.value.emit(value);
}
get on(): boolean {
return this._on;
set(value: boolean): void {
this.checked.set(value);
}
}

View file

@ -0,0 +1,192 @@
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
/**
* Tasks accordion shows pending (not-done) blocks inside a tower.
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
* Clicking the colored tickbox marks the task done.
* Clicking the description opens the block-edit modal via the `edit` output.
*/
@Component({
selector: 'lt-tasks',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)"
>
<p class="header">
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p>
<div
class="all-task"
#all
[style.height.px]="expanded() ? all.scrollHeight : 0"
>
@for (b of pending(); track b.id) {
<div class="task-container">
<button
type="button"
class="tickbox"
[style.background-color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<p
[style.color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p>
</div>
}
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host {
width: 100%;
box-sizing: border-box;
position: relative;
// Within the tower stacking context: high enough to float above the
// falling-blocks layer. Globally low enough that modals + the carousel
// (10000+) always cover us.
z-index: 5;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover {
box-shadow: $shadow-border;
}
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
max-height: 30vh;
overflow-y: auto;
.header { cursor: pointer; }
p { font-size: var(--medium-font-size); }
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex;
align-items: center;
gap: var(--small-padding);
&:hover p {
@media (min-width: $mobile-width) {
color: inherit !important;
}
}
// Tickbox: a generously sized colored button that marks the task
// done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark.
.tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles
cursor: pointer;
position: relative;
box-sizing: border-box;
@include square(24px);
@media (max-width: $mobile-width) {
@include square(20px);
}
border-radius: 4px;
box-shadow: $shadow-border;
transition: transform $short-animation-time, box-shadow $long-animation-time;
&::after {
content: '✓';
position: absolute;
inset: 0;
@include center-child();
color: $light-color;
font-size: 18px;
font-weight: bold;
line-height: 1;
opacity: 0.5;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
transform: translateY(2px);
transition: opacity $short-animation-time, transform $short-animation-time;
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
transform: scale(1.05);
&::after { opacity: 0.85; }
}
&:active {
transform: scale(0.95);
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
}
}
p {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
flex: 1 1 auto;
cursor: pointer;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}
`,
})
export class TasksComponent {
readonly pending = input.required<Block[]>();
readonly baseColor = input.required<HslColor>();
/** When true, the accordion starts expanded on first render. */
readonly initiallyOpen = input<boolean>(false);
/** Emitted when the colored tickbox is clicked — parent flips is_done to true. */
readonly markDone = output<Block>();
/** Emitted when the description is clicked — parent opens the block-edit modal. */
readonly edit = output<Block>();
readonly expanded = signal(false);
constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
// the "Keep tasks open" page setting expands/collapses the accordion live.
// User clicks (which mutate `expanded` directly) are respected until the
// setting changes again.
effect(() => {
const open = this.initiallyOpen();
untracked(() => this.expanded.set(open));
});
}
colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor());
}
}

View file

@ -0,0 +1,469 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
import { Tower, Block } from '../../models';
import { BlockComponent } from '../block/block.component';
import { TasksComponent } from '../tasks/tasks.component';
import { BlockEditComponent, BlockEditSave } from '../modal/block-edit.component';
import { ModalComponent } from '../modal/modal.component';
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
import { toCss } from '../../utils/color';
/** Tracks which entry path the block-edit modal was opened from. */
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
/** A done block augmented with per-render animation state. */
interface StyledBlock extends Block {
_anim: '' | 'descend' | 'ascend';
_transform: string;
_opacity: string;
}
@Component({
selector: 'lt-tower',
standalone: true,
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tower">
<div class="container" [class.trash-highlight]="trashHighlight()">
<lt-tasks
[pending]="pending()"
[baseColor]="tower().base_color"
[initiallyOpen]="keepTasksOpen()"
(markDone)="onMarkTaskDone($event)"
(edit)="onEditBlock($event)"
/>
<img
src="assets/plus-sign.svg"
class="add-block"
alt="Add block"
(click)="$event.stopPropagation(); openAddBlock()"
/>
<div class="block-container-container">
<div class="block-container">
@for (b of visibleBlocks(); track b.id) {
<lt-block
[block]="b"
[baseColor]="tower().base_color"
[class]="b._anim"
[style.transform]="b._transform"
[style.opacity]="b._opacity"
(clicked)="onEditBlock(b)"
/>
}
</div>
</div>
</div>
<input
#nameInput
type="text"
[value]="tower().name"
[style.color]="towerNameCss()"
(blur)="onRename($event)"
(keydown.enter)="nameInput.blur()"
/>
</div>
@if (editEntry(); as entry) {
<lt-modal (close)="closeEdit()">
<lt-block-edit
[blocks]="filteredForEntry()"
[activeBlockId]="entry.activeId"
[tags]="towerTags()"
[baseColor]="tower().base_color"
[defaultDone]="entry.filter === 'done'"
(save)="onBlockSave($event)"
(delete)="onBlockDelete($event)"
(close)="closeEdit()"
/>
</lt-modal>
}
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
</lt-modal>
}
`,
styles: `
@import '../../../library/main';
:host {
cursor: pointer;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
&.cdk-drag-placeholder {
opacity: 0;
}
&:hover {
@media (min-width: $mobile-width) {
div.container {
box-shadow: $shadow;
}
}
}
&.cdk-drag-preview {
div.container {
@media (max-width: $mobile-width) {
@keyframes shadow {
from { box-shadow: none; }
to { box-shadow: $shadow; }
}
animation: shadow $long-animation-time forwards;
}
}
}
&.trash-highlight {
.container {
transform: scale(0.75);
position: relative;
:before {
opacity: 0.5 !important;
}
}
input {
display: none;
}
}
.tower {
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%;
height: 100%;
@include inner-spacing(var(--small-padding));
.container {
display: flex;
flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before {
content: '';
pointer-events: none;
position: absolute;
z-index: 2;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: red;
opacity: 0;
border-radius: var(--border-radius);
transition: opacity $short-animation-time;
}
lt-tasks {
flex: 0 0 auto;
min-height: 56px;
max-height: 30vh;
overflow: hidden;
display: block;
width: 100%;
.container {
max-height: 100%;
overflow-y: auto;
}
}
img.add-block {
flex: 0 0 auto;
align-self: center;
margin: var(--medium-padding) 0;
}
img {
position: relative;
z-index: 2;
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
/* Default resting position for all blocks before JS sets them */
* {
transform: translateY(500%);
}
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
}
}
}
}
input[type='text'] {
font-size: var(--small-font-size);
text-align: center;
@media (min-width: $mobile-width) {
width: 50%;
}
}
}
}
`,
})
export class TowerComponent {
// ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required<Tower>();
/** Set by page component when this tower is being dragged over trash. */
readonly trashHighlight = input<boolean>(false);
/** Optional date range filter when set, blocks with `created_at`
* outside [from, to] are hidden from the falling stack. */
readonly dateRange = input<{ from: number; to: number } | null>(null);
/** When true, the tasks accordion starts expanded on load. */
readonly keepTasksOpen = input<boolean>(false);
// ── Outputs ────────────────────────────────────────────────────────────────
readonly updateTower = output<TowerSettingsResult>();
readonly deleteTowerRequest = output<void>();
/** Emitted when a new block is created from the carousel's "Create now" card. */
readonly addBlock = output<{ tag: string; description: string; is_done: boolean }>();
/** Emitted when an existing block is patched from the carousel. */
readonly saveBlock = output<{
blockId: string;
result: { tag: string; description: string; is_done: boolean };
}>();
readonly deleteBlock = output<string>();
// ── UI state ───────────────────────────────────────────────────────────────
/** The single source of truth for "block-edit modal open" encodes both
* which list of blocks to show and which one to focus initially. */
readonly editEntry = signal<EditEntry | null>(null);
readonly showSettings = signal(false);
// ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */
readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
/** CSS color string for the tower name input. */
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
/** Filtered list passed to the block-edit carousel. */
readonly filteredForEntry = computed(() => {
const entry = this.editEntry();
if (!entry) return [];
const isDone = entry.filter === 'done';
return this.tower().blocks.filter(b => b.is_done === isDone);
});
/** Unique tags from existing blocks of this tower. */
readonly towerTags = computed(() => {
const set = new Set<string>();
for (const b of this.tower().blocks) if (b.tag) set.add(b.tag);
return [...set];
});
// ── Falling animation ──────────────────────────────────────────────────────
// Same approach as the legacy: detect "exactly one done block was added"
// and snap that last block to translateY(500%)/opacity:0, then on next
// tick flip it back to translateY(0)/opacity:1 with .descend so the
// 1.5s gravity transition fires.
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly();
private prevDoneIds: string[] = [];
private isFirstRun = true;
constructor() {
effect(() => {
const range = this.dateRange();
// Always keep ALL done blocks in the visible list. The date filter
// only flips per-block `_anim` so the 1.5s ascend/descend transition
// can animate them in and out of the falling stack.
const allDone = this.tower().blocks.filter((b) => b.is_done);
untracked(() => this.reconcile(allDone, range));
});
}
private reconcile(allDone: Block[], range: { from: number; to: number } | null): void {
const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds;
const prevSet = new Set(prev);
const newIds = ids.filter(id => !prevSet.has(id));
const grewByOne =
!this.isFirstRun &&
ids.length === prev.length + 1 &&
newIds.length === 1 &&
prev.every(id => ids.includes(id)); // no IDs disappeared
const inRange = (b: Block) =>
!range || (b.created_at >= range.from && b.created_at <= range.to);
const styled: StyledBlock[] = [];
for (const b of allDone) {
if (range && b.created_at < range.from) {
// Below min-thumb boundary → drop entirely (instant shuffle, no animation).
continue;
}
if (range && b.created_at > range.to) {
// Above max-thumb boundary → fly up off the tower with gravity animation.
styled.push({
...b,
_anim: 'ascend',
_transform: 'translateY(500%)',
_opacity: '0',
});
continue;
}
// In range — descend into position (or appear instantly on first run).
styled.push({
...b,
_anim: this.isFirstRun ? '' : 'descend',
_transform: 'translateY(0)',
_opacity: '1',
});
}
if (grewByOne) {
const newId = newIds[0];
const newBlock = styled.find(b => b.id === newId);
if (newBlock && inRange(newBlock)) {
// Snap newly-added in-range block to start position, then on the next
// paint flip it back to rest — that's what makes it visibly fall.
newBlock._anim = '';
newBlock._transform = 'translateY(500%)';
newBlock._opacity = '0';
this._visibleBlocks.set(styled);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
newBlock._anim = 'descend';
newBlock._transform = 'translateY(0)';
newBlock._opacity = '1';
this._visibleBlocks.set([...this._visibleBlocks()]);
});
});
this.prevDoneIds = ids;
this.isFirstRun = false;
return;
}
}
// existing fall-through path (no growth, first run, or new block out of range):
this._visibleBlocks.set(styled);
this.prevDoneIds = ids;
this.isFirstRun = false;
}
// ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void {
const input = event.target as HTMLInputElement;
const newName = input.value.trim();
if (newName && newName !== this.tower().name) {
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
}
}
onEditBlock(block: Block): void {
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
}
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
onMarkTaskDone(block: Block): void {
this.saveBlock.emit({
blockId: block.id,
result: { tag: block.tag, description: block.description, is_done: true },
});
}
/** Called by the template "Add block" plus-icon. */
openAddBlock(): void {
this.editEntry.set({ filter: 'done', activeId: null });
}
closeEdit(): void {
this.editEntry.set(null);
}
onBlockSave(ev: BlockEditSave): void {
if (ev.id === null) {
this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
} else {
this.saveBlock.emit({
blockId: ev.id,
result: { tag: ev.tag, description: ev.description, is_done: ev.is_done },
});
}
}
onBlockDelete(id: string): void {
// Don't close the carousel — the deleted block disappears from `blocks()`
// and the carousel re-renders in place. The user keeps editing siblings.
this.deleteBlock.emit(id);
}
onTowerSave(result: TowerSettingsResult): void {
this.showSettings.set(false);
this.updateTower.emit(result);
}
onTowerDelete(): void {
this.showSettings.set(false);
this.deleteTowerRequest.emit();
}
}

View file

@ -0,0 +1,88 @@
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
@Component({
selector: 'lt-welcome',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="welcome-card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Welcome to Life Towers</h2>
<p class="lead">
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
tower as a colored square the more you do, the taller it grows.
</p>
<p class="muted">
Everything you write is saved to a small remote database, keyed to a private UUID token shown
under <em>Settings Account</em>. Copy that token to recover your data on another device, or
paste a friend's token to look at theirs.
</p>
<div class="actions">
<button type="button" (click)="startFresh.emit()">Start fresh</button>
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host { display: block; }
.welcome-card {
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
@include inner-spacing(var(--medium-padding));
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
h2 {
text-align: center;
margin-bottom: var(--medium-padding);
}
p.lead { color: $text-color; }
p.muted {
color: rgba($text-color, 0.7);
font-size: var(--medium-font-size);
em { font-style: italic; }
}
.actions {
display: flex;
justify-content: space-around;
gap: var(--large-padding);
margin-top: var(--large-padding);
button.primary {
color: $accent-color;
border-bottom-color: rgba($accent-color, 0.33);
&:after { background-color: $accent-color; }
}
}
}
`,
})
export class WelcomeComponent {
readonly close = output<void>();
readonly startFresh = output<void>();
readonly loadExample = output<void>();
}

View file

@ -1,5 +0,0 @@
export interface IColor {
h: number;
s: number;
l: number;
}

View file

@ -1,8 +0,0 @@
import { IUnique } from './unique';
export interface IBlock extends IUnique {
created: Date;
tag: string;
isDone: boolean;
description: string;
}

View file

@ -1,6 +0,0 @@
import { IPage } from './page';
import { IUnique } from './unique';
export interface IData extends IUnique {
pages: Array<IPage>;
}

View file

@ -1,13 +0,0 @@
import { ITower } from './tower';
import { Range } from '../range';
import { IUnique } from './unique';
export interface IPage extends IUnique {
name: string;
towers: ITower[];
userData: {
hideCreateTowerButton: boolean;
defaultDateRange: Range<Date>;
};
}

Some files were not shown because too many files have changed in this diff Show more