This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
commit f74ee43cb4
158 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

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