Compare commits

..

2 Commits

Author SHA1 Message Date
1de7f33097 Merge pull request 'Upgraded dependencies and extended stopwatch' (#37) from feature/Update into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m26s
Build, Test & Push Frontend / docker (push) Successful in 1m20s
Reviewed-on: #37
2026-08-20 12:01:33 +02:00
Andreas Dahm
f543a5e2f3 Upgraded dependencies and extended stopwatch
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-08-20 12:00:39 +02:00
31 changed files with 2829 additions and 2551 deletions

2
.gitattributes vendored
View File

@@ -34,7 +34,7 @@ Dockerfile text eol=lf
*.adoc text eol=lf
*.txt text eol=lf
*.csv text eol=lf
*.svg text eol=lf # svg ist Text
*.svg text eol=lf
# ---- binary files never convert ----
*.jar binary

5
.postcssrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

View File

@@ -10,7 +10,7 @@ This is the frontend of the Playground project, built with Angular 21 and Angula
* **Internationalization:** ngx-translate
* **Server:** NGINX (for serving the SPA)
* **Containerization:** Docker
* **CI/CD:** GitHub Actions
* **CI/CD:** Gitea Actions (self-hosted, `git.andreas-dahm.eu`)
## Building and Running
@@ -77,7 +77,7 @@ ng build
* **Language:** TypeScript
* **Framework:** Angular
* **Styling:** Tailwind CSS (v3) is the primary styling approach. Use utility classes in templates. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@layer components`.
* **Styling:** Tailwind CSS (v4, CSS-first config) is the primary styling approach. Use utility classes in templates. Theme tokens and custom variants are declared in `src/tailwind.css` via `@theme` and `@custom-variant` — there is no `tailwind.config.js`. Preflight stays disabled by importing `tailwindcss/theme.css` and `tailwindcss/utilities.css` individually, so Angular Material's reset stays in charge. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@utility`.
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.

121
README.md
View File

@@ -1,9 +1,8 @@
## 📗 `playground-frontend/README.md`
# 🎨 Playground Frontend
This is the **frontend** of the Playground project.
Built with **Angular 21** and **Angular Material**, including a simple light/dark theme toggle and multi-language support via **ngx-translate**.
Built with **Angular 21** and **Angular Material**, including a light/dark theme toggle and
multi-language support (EN/DE) via **ngx-translate**.
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
deployed at:
@@ -12,84 +11,136 @@ deployed at:
---
## 🧩 Tech Stack
| Component | Purpose |
|------------|----------|
| Angular 21 | Frontend framework |
| Angular Material | UI components & theming |
| ngx-translate | i18n / instant translation |
| --------- | ------- |
| Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
| Angular Material 21 | UI components & theming |
| Tailwind CSS 4 | Utility-first styling (CSS-first config, no `tailwind.config.js`) |
| ngx-translate 18 | i18n / instant translation |
| Babylon.js 9 | WebGL/WebGPU rendering for the algorithm demos |
| Swiper 14 | Image carousels in the project dialogs |
| Angular Service Worker | PWA / offline caching |
| NGINX | Serves the compiled SPA |
| Docker + GitHub Actions | Automated build & image publishing |
| Docker + Gitea Actions | Automated build, test & image publishing |
Requires **Node.js 22**.
---
## 📁 Project Structure
```
playground-frontend/
├─ src/
│ ├─ app/
├─ app.component.ts
├─ theme.service.ts
│ │ └─ ...
├─ assets/i18n/
├─ en.json
│ │ └─ de.json
├─ layout/ # app shell, topbar, dialogs
├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
├─ service/ # theme, language, SEO, GPU capability
│ ├─ shared/ # reusable components & helpers
├─ constants/
├─ app.config.ts
│ │ └─ app.routes.ts
│ ├─ assets/
│ │ ├─ i18n/ # en.json / de.json (keys must stay in sync)
│ │ ├─ logos/, flags/, icons/
│ │ └─ projects/ # project screenshots
│ ├─ tailwind.css # Tailwind entry + @theme tokens
│ ├─ styles.scss # Material theme setup & component overrides
│ └─ index.html
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
├─ Dockerfile
├─ nginx.conf
.github/workflows/docker.yml
ngsw-config.json # service worker caching rules
├─ lighthouserc.json
└─ .gitea/workflows/build-Frontend-a.yml
```
---
## 🚀 Local Development
# 1. Install dependencies
**1. Install dependencies**
```bash
npm install
```
# 2. Start development server
**2. Start development server**
```bash
ng serve --open
```
App runs at http://localhost:4200
# 3. 🐳 Docker Build (local)
**3. Docker build (local)**
```bash
docker build -t playground-frontend:local .
docker run -p 8080:80 playground-frontend:local
```
Then open http://localhost:8080
## ⚙️ GitHub Actions (CI/CD)
---
On every push to main, GitHub Actions will:
## 🧪 Scripts
Build the Angular project
| Command | Purpose |
| ------- | ------- |
| `npm start` | Dev server |
| `npm run build` | Production build |
| `npm run watch` | Rebuild on change (development configuration) |
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
1. Create a Docker image
2. Push it to Docker Hub (docker.io/andreasdahm/playground-frontend:main)
3. Workflow file: .github/workflows/docker.yml
---
Required repository secrets:
## ⚙️ Gitea Actions (CI/CD)
Name Description
DOCKERHUB_USERNAME Your Docker Hub username
DOCKERHUB_TOKEN Personal access token for Docker Hub
CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
Workflow file: `.gitea/workflows/build-Frontend-a.yml`
Triggered on every push to `main` and on pull requests targeting `main`.
**Job 1 — `quality-check`** (runs on both pushes and PRs):
1. Lint & type check — `npm run lint`
2. i18n key sync — `npm run i18n:check`
3. Unit tests — `npx ng test --watch=false`
4. Production build — `npx ng build --configuration production`
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
**Job 2 — `docker`** (only on a push to `main`, and only if `quality-check` passed):
Builds the image and pushes it to the Gitea container registry at `git.andreas-dahm.eu`
with these tags:
```
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>-<short-sha>
git.andreas-dahm.eu/<owner>/playground:frontend-a-main
```
**Required repository secret:**
| Name | Description |
| ---- | ----------- |
| `TOKEN_GITEA` | Gitea access token used to authenticate against the container registry |
---
## 🌐 Deployment
The built image is deployed via
https://github.com/lobothedark/playground-deploy
on the Hostinger KVM server using Traefik.
The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
server using **Traefik**:
```bash
docker compose pull
docker compose up -d
```
✅ Live site at https://app.andreas-dahm.eu
---
## 🪄 Maintainer
Andreas Dahm

4772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,42 +13,44 @@
"private": true,
"dependencies": {
"@angular-slider/ngx-slider": "^21.0.0",
"@angular/animations": "~21.2.9",
"@angular/animations": "~21.2.21",
"@angular/cdk": "~21.2.6",
"@angular/common": "~21.2.9",
"@angular/compiler": "~21.2.9",
"@angular/core": "~21.2.9",
"@angular/forms": "~21.2.9",
"@angular/common": "~21.2.21",
"@angular/compiler": "~21.2.21",
"@angular/core": "~21.2.21",
"@angular/forms": "~21.2.21",
"@angular/material": "~21.2.6",
"@angular/platform-browser": "~21.2.9",
"@angular/router": "~21.2.9",
"@angular/service-worker": "21.2.9",
"@babylonjs/core": "^8.54.1",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@angular/platform-browser": "~21.2.21",
"@angular/router": "~21.2.21",
"@angular/service-worker": "~21.2.21",
"@babylonjs/core": "^9.22.0",
"@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0",
"rxjs": "~7.8.2",
"swiper": "~12.1.0",
"swiper": "^14.1.0",
"tslib": "~2.8.1"
},
"devDependencies": {
"@angular/build": "~21.2.7",
"@angular/build": "~21.2.21",
"@angular/cli": "~21.2.7",
"@angular/compiler-cli": "~21.2.9",
"@angular/compiler-cli": "~21.2.21",
"@eslint/js": "~10.0.1",
"@lhci/cli": "^0.15.1",
"@tailwindcss/postcss": "^4.3.3",
"@types/jasmine": "~6.0.0",
"@webgpu/types": "^0.1.72",
"angular-eslint": "21.3.1",
"eslint": "^10.0.3",
"jasmine-core": "~6.1.0",
"jasmine-core": "^7.0.2",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.2.0",
"puppeteer": "^25.1.0",
"tailwindcss": "^3.4.19",
"tailwindcss": "^4.3.3",
"typescript": "~5.9.3",
"typescript-eslint": "8.58.2"
"typescript-eslint": "^8.67.0"
},
"overrides": {
"tmp": "^0.2.3",

View File

@@ -1,10 +1,10 @@
<app-particles-background></app-particles-background>
<app-topbar />
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-[220ms]">
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
<router-outlet />
</main>
<footer class="border-t border-black/[.08] p-fluid-md text-center opacity-80 bg-app-bg">
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
<br />
<small>{{ `APP.AI_NOTICE` | translate }}</small>

View File

@@ -1,4 +1,4 @@
<mat-toolbar class="!flex !items-center !p-[clamp(0.5rem,1vw,1rem)] !backdrop-blur-[8px] !backdrop-saturate-[1.1] !bg-white/80 dark:!bg-[#313131]/80 !border-b !border-black/[.08]" color="primary" (keydown)="onKeydown($event)">
<mat-toolbar class="flex! items-center! p-[clamp(0.5rem,1vw,1rem)]! backdrop-blur-sm! backdrop-saturate-[1.1]! bg-white/80! dark:bg-[#313131]/80! border-b! border-black/8!" color="primary" (keydown)="onKeydown($event)">
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
<img class="w-[clamp(36px,10vw,48px)] h-[clamp(36px,10vw,48px)] rounded-full" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
oncontextmenu="return false;">
@@ -6,15 +6,15 @@
</a>
<nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
</nav>

View File

@@ -5,7 +5,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { TranslatePipe } from '@ngx-translate/core';
import { ThemeService } from '../../service/theme.service';
import { LanguageService } from '../../service/language.service';
import { MatDivider } from '@angular/material/divider';
@@ -17,7 +17,7 @@ import {RouterConstants} from '../../constants/RouterConstants';
imports: [
RouterLink, RouterLinkActive,
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
TranslateModule, MatDivider
TranslatePipe, MatDivider
],
templateUrl: './topbar.component.html',
styleUrl: './topbar.component.scss'

View File

@@ -124,7 +124,7 @@
@if (entry.externalLink) {
<div class="mt-[0.1rem] opacity-85">
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">open_in_new</mat-icon>
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
</a>
</div>
@@ -132,7 +132,7 @@
<div class="mt-[0.1rem] opacity-85">
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
rel="noopener noreferrer">
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">link</mat-icon>
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">link</mat-icon>
{{ (entry.key + '.LINK_INTERNAL') | translate }}
</a>
</div>
@@ -169,7 +169,7 @@
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
{
<mat-divider class="!my-2"></mat-divider>
<mat-divider class="my-2!"></mat-divider>
}
}
</div>

View File

@@ -5,7 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { TranslateModule } from '@ngx-translate/core';
import { TranslatePipe } from '@ngx-translate/core';
import {UrlConstants} from '../../constants/UrlConstants';
import {AssetsConstants} from '../../constants/AssetsConstants';
import {RouterLink} from '@angular/router';
@@ -21,7 +21,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
MatIconModule,
MatButtonModule,
MatDividerModule,
TranslateModule,
TranslatePipe,
RouterLink
],
templateUrl: './about.component.html',

View File

@@ -5,8 +5,8 @@
@for (category of categories; track category.id) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
<mat-card-content>
<div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
<mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
<div class="flex items-center text-(--mat-sys-primary) mb-4">
<mat-icon class="text-[26px]! w-[26px]! h-[26px]!">{{ category.icon }}</mat-icon>
</div>
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>

View File

@@ -47,7 +47,7 @@
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div>
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
[ngClass]="{
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',

View File

@@ -5,7 +5,7 @@ import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
@@ -25,7 +25,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
MatCardModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
TranslatePipe,
Information
],
templateUrl: './four-color.component.html',

View File

@@ -18,7 +18,7 @@
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>

View File

@@ -8,7 +8,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
import {PathfindingService} from './service/pathfinding.service';
@@ -35,7 +35,7 @@ enum NodeType {
MatButtonToggleModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
TranslatePipe,
MatCard,
MatCardHeader,
MatCardTitle,

View File

@@ -49,7 +49,7 @@
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
@for (item of sortArray; track $index) {
<div
class="grow w-2.5 min-w-px transition-all duration-[50ms]"
class="grow w-2.5 min-w-px transition-all duration-50"
[style.height.px]="item.value * 3"
[ngClass]="{
'bg-[#424242]': item.state === 'unsorted',

View File

@@ -5,7 +5,7 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select";
import {MatButtonModule} from "@angular/material/button";
import {MatIconModule} from "@angular/material/icon";
import {TranslateModule} from "@ngx-translate/core";
import {TranslatePipe} from "@ngx-translate/core";
import { SortingService } from './service/sorting.service';
import { SortingAudioService } from './service/sorting-audio.service';
import {SortData, SortSnapshot} from './sorting.models';
@@ -16,7 +16,7 @@ import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
@Component({
selector: 'app-sorting',
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
templateUrl: './sorting.component.html',
styleUrl: './sorting.component.scss'
})

View File

@@ -13,9 +13,9 @@
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
<mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
</div>
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@@ -25,9 +25,9 @@
</ul>
</div>
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
<mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
</div>
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@@ -46,7 +46,7 @@
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
@for (img of project.images; track img) {
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
<img class="w-full h-auto !max-h-[clamp(300px,60vh,512px)] object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
@if (img.source) {
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
{{ img.source }}

View File

@@ -1,6 +1,6 @@
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
@if (featuredProject(); as project) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
@@ -27,7 +27,7 @@
}
@for (project of otherProjects(); track project) {
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header>

View File

@@ -7,7 +7,7 @@
<div
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
[class]="flashing()
? 'scale-110 !text-[var(--mat-sys-primary)] !ring-[var(--mat-sys-primary)] shadow-[0_0_40px_var(--mat-sys-primary)]'
? 'scale-110 text-(--mat-sys-primary)! ring-(--mat-sys-primary)! shadow-[0_0_40px_var(--mat-sys-primary)]'
: ''"
>
{{ display() }}
@@ -37,6 +37,8 @@
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
</mat-checkbox>
<p class="m-0 font-medium text-sm">{{ 'STOPWATCH.FIRST_INTERVAL' | translate }}</p>
<div class="flex flex-wrap gap-3 items-center justify-center">
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
@@ -46,8 +48,8 @@
min="0"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalMinutes()"
(ngModelChange)="intervalMinutes.set($event)"
[ngModel]="firstIntervalMinutes()"
(ngModelChange)="updateIntervalValue(firstIntervalMinutes, $event)"
/>
</mat-form-field>
@@ -60,15 +62,60 @@
max="59"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalSeconds()"
(ngModelChange)="intervalSeconds.set($event)"
[ngModel]="firstIntervalSeconds()"
(ngModelChange)="updateIntervalValue(firstIntervalSeconds, $event)"
/>
</mat-form-field>
</div>
<mat-checkbox
[checked]="secondIntervalEnabled()"
[disabled]="!intervalEnabled()"
(change)="secondIntervalEnabled.set($event.checked)"
>
{{ 'STOPWATCH.SECOND_INTERVAL_ENABLED' | translate }}
</mat-checkbox>
@if (secondIntervalEnabled()) {
<div class="flex flex-wrap gap-3 items-center justify-center">
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="secondIntervalMinutes()"
(ngModelChange)="updateIntervalValue(secondIntervalMinutes, $event)"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
max="59"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="secondIntervalSeconds()"
(ngModelChange)="updateIntervalValue(secondIntervalSeconds, $event)"
/>
</mat-form-field>
</div>
}
<p class="m-0 opacity-70 text-sm text-center">
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
</p>
@if (intervalEnabled() && schedulePreview()) {
<p class="m-0 opacity-70 text-sm text-center">
{{ 'STOPWATCH.INTERVAL_SCHEDULE' | translate }} {{ schedulePreview() }} …
</p>
}
</div>
</div>
</mat-card-content>

View File

@@ -0,0 +1,39 @@
import {collectBeepTimes, countBeeps, slotOfBeep} from './stopwatch.component';
const MINUTE = 60_000;
describe('stopwatch interval scheduling', () => {
const firstMs = 1 * MINUTE;
const secondMs = 2 * MINUTE;
it('counts no beeps without a configured first interval', () => {
expect(countBeeps(10 * MINUTE, 0, secondMs)).toBe(0);
});
it('falls back to a single repeating interval when the second one is off', () => {
expect(countBeeps(0.9 * MINUTE, firstMs, 0)).toBe(0);
expect(countBeeps(1 * MINUTE, firstMs, 0)).toBe(1);
expect(countBeeps(3 * MINUTE, firstMs, 0)).toBe(3);
});
it('alternates both intervals so beeps fall on 1, 3, 4, 6, 7 minutes', () => {
const expectedBeepCountPerMinute = [0, 1, 1, 2, 3, 3, 4, 5, 5, 6];
expectedBeepCountPerMinute.forEach((expected, minute) => {
expect(countBeeps(minute * MINUTE, firstMs, secondMs)).toBe(expected);
});
});
it('reports the alternating beep times', () => {
const times = collectBeepTimes(firstMs, secondMs, 5);
expect(times).toEqual([1 * MINUTE, 3 * MINUTE, 4 * MINUTE, 6 * MINUTE, 7 * MINUTE]);
});
it('assigns odd beeps to the first interval and even ones to the second', () => {
expect(slotOfBeep(1, secondMs)).toBe('first');
expect(slotOfBeep(2, secondMs)).toBe('second');
expect(slotOfBeep(3, secondMs)).toBe('first');
expect(slotOfBeep(2, 0)).toBe('first');
});
});

View File

@@ -1,4 +1,4 @@
import {Component, computed, effect, inject, OnDestroy, signal} from '@angular/core';
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
@@ -7,10 +7,16 @@ import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {Title} from '@angular/platform-browser';
import {TranslateModule} from '@ngx-translate/core';
import {TranslatePipe} from '@ngx-translate/core';
type StopwatchState = 'idle' | 'running' | 'paused';
/** Which of the two configured intervals a beep belongs to. */
type IntervalSlot = 'first' | 'second';
const FIRST_INTERVAL_FREQUENCY_HZ = 880;
const SECOND_INTERVAL_FREQUENCY_HZ = 587;
@Component({
selector: 'app-stopwatch',
standalone: true,
@@ -22,7 +28,7 @@ type StopwatchState = 'idle' | 'running' | 'paused';
MatIconModule,
MatInputModule,
MatCheckboxModule,
TranslateModule
TranslatePipe
],
templateUrl: './stopwatch.component.html'
})
@@ -31,13 +37,34 @@ export class StopwatchComponent implements OnDestroy {
readonly elapsedMs = signal(0);
readonly intervalEnabled = signal(false);
readonly intervalMinutes = signal(2);
readonly intervalSeconds = signal(0);
readonly firstIntervalMinutes = signal(1);
readonly firstIntervalSeconds = signal(0);
readonly secondIntervalEnabled = signal(false);
readonly secondIntervalMinutes = signal(2);
readonly secondIntervalSeconds = signal(0);
readonly flashing = signal(false);
readonly display = computed(() => formatElapsed(this.elapsedMs()));
readonly firstIntervalMs = computed(
() => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
);
readonly secondIntervalMs = computed(() => {
if (!this.secondIntervalEnabled()) {
return 0;
}
return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
});
/** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
readonly schedulePreview = computed(() => {
const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
return beepTimes.map(formatElapsedShort).join(' · ');
});
private intervalId: number | null = null;
private startTimestamp = 0;
private accumulatedMs = 0;
@@ -88,6 +115,11 @@ export class StopwatchComponent implements OnDestroy {
this.stop();
}
/** Number inputs can emit empty strings or negative values, so sanitize before storing. */
updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
target.set(toNonNegativeInteger(value));
}
ngOnDestroy(): void {
this.cancelTick();
if (this.flashTimeoutId !== null) {
@@ -140,17 +172,19 @@ export class StopwatchComponent implements OnDestroy {
if (!this.intervalEnabled()) {
return;
}
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
if (intervalMs <= 0) {
const firstMs = this.firstIntervalMs();
const secondMs = this.secondIntervalMs();
const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
const currentBeeps = countBeeps(currMs, firstMs, secondMs);
if (currentBeeps <= previousBeeps) {
return;
}
const prevTick = Math.floor(prevMs / intervalMs);
const currTick = Math.floor(currMs / intervalMs);
if (currTick > prevTick) {
this.beep();
const slot = slotOfBeep(currentBeeps, secondMs);
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
this.triggerFlash();
}
}
private triggerFlash(): void {
if (this.flashTimeoutId !== null) {
@@ -180,7 +214,7 @@ export class StopwatchComponent implements OnDestroy {
}
}
private beep(): void {
private beep(frequencyHz: number): void {
if (!this.audioContext) {
return;
}
@@ -189,7 +223,7 @@ export class StopwatchComponent implements OnDestroy {
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = 880;
oscillator.frequency.value = frequencyHz;
const now = ctx.currentTime;
const duration = 0.18;
@@ -204,6 +238,62 @@ export class StopwatchComponent implements OnDestroy {
}
}
function toMilliseconds(minutes: number, seconds: number): number {
return minutes * 60_000 + seconds * 1_000;
}
function toNonNegativeInteger(value: unknown): number {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
/**
* The two intervals alternate: the first beep lands after `firstMs`, the next one
* `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
* beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
*/
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
if (firstMs <= 0) {
return 0;
}
if (secondMs <= 0) {
return Math.floor(elapsedMs / firstMs);
}
const cycleMs = firstMs + secondMs;
const completedCycles = Math.floor(elapsedMs / cycleMs);
const remainderMs = elapsedMs % cycleMs;
const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;
return completedCycles * 2 + firstBeepOfCycleReached;
}
/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
if (secondMs <= 0) {
return 'first';
}
return beepNumber % 2 === 1 ? 'first' : 'second';
}
export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
if (firstMs <= 0) {
return [];
}
const times: number[] = [];
let current = 0;
for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
current += isFirstSlot ? firstMs : secondMs;
times.push(current);
}
return times;
}
function formatElapsed(totalMs: number): string {
const ms = Math.floor(totalMs % 1000);
const totalSeconds = Math.floor(totalMs / 1000);

View File

@@ -1,5 +1,5 @@
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #gridCanvas
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
</canvas>
</div>

View File

@@ -1,5 +1,5 @@
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #renderCanvas
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
</canvas>
</div>

View File

@@ -598,9 +598,12 @@
"STOP": "Stopp",
"RESET": "Zurücksetzen",
"INTERVAL_ENABLED": "Signalton bei Intervall",
"FIRST_INTERVAL": "Erstes Intervall",
"INTERVAL_MINUTES": "Minuten",
"INTERVAL_SECONDS": "Sekunden",
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
"SECOND_INTERVAL_ENABLED": "Zweites, abwechselndes Intervall hinzufügen",
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald ein Intervall abgelaufen ist. Mit aktiviertem zweiten Intervall wechseln sich beide ab, das zweite startet direkt nach dem ersten.",
"INTERVAL_SCHEDULE": "Nächste Signaltöne bei:"
},
"NOT_FOUND": {
"TITLE": "Seite nicht gefunden",

View File

@@ -598,9 +598,12 @@
"STOP": "Stop",
"RESET": "Reset",
"INTERVAL_ENABLED": "Beep at interval",
"FIRST_INTERVAL": "First interval",
"INTERVAL_MINUTES": "Minutes",
"INTERVAL_SECONDS": "Seconds",
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
"SECOND_INTERVAL_ENABLED": "Add a second, alternating interval",
"INTERVAL_HINT": "Plays a short tone every time an interval elapses. With a second interval enabled both alternate, so the second one starts right after the first.",
"INTERVAL_SCHEDULE": "Next beeps at:"
},
"NOT_FOUND": {
"TITLE": "Page not found",

View File

@@ -1,6 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
Tailwind v4 dropped `corePlugins`, so preflight can no longer be switched off
from the config. Importing the layers individually and leaving out
`preflight.css` keeps Angular Material's own reset in charge.
*/
@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
/*
The theme is driven by the CSS custom properties that styles.scss sets per theme.
`inline` is required: the theme service toggles `.dark` on <body>, while a plain
`@theme` would resolve these references once at `:root` (<html>) and freeze the
light values. Inlining makes the utilities emit `var(--app-fg)` directly, so they
resolve on the element and follow the `.dark` override again.
*/
@theme inline {
--color-app-bg: var(--app-bg);
--color-app-fg: var(--app-fg);
--color-card-bg: var(--card-bg);
--color-link: var(--link-color);
--color-link-hover: var(--link-color-hover);
--color-logo-bg: var(--app-logo-bg);
--container-app: var(--app-maxWidth);
--radius-card: var(--card-radius);
--font-sans: Inter, Roboto, Arial, sans-serif;
--spacing-fluid-sm: clamp(0.5rem, 2vw, 1rem);
--spacing-fluid-md: clamp(1rem, 3vw, 1.5rem);
--spacing-fluid-lg: clamp(1rem, 4vw, 2rem);
}
/* The theme service toggles a `dark` class instead of relying on the media query. */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant mobile (@media (width <= 760px));
@custom-variant sm-dialog (@media (width <= 600px));
@custom-variant tablet (@media (width <= 900px));
@utility legend-swatch {
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
}
@utility card-gradient-bar {
@apply relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
var(--mat-sys-primary),
var(--mat-sys-tertiary)
);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
}
@layer base {
*,
@@ -44,24 +105,3 @@
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
}
@layer components {
.legend-swatch {
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
}
.card-gradient-bar {
@apply relative;
}
.card-gradient-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
}

View File

@@ -1,37 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,ts}'],
darkMode: 'class',
corePlugins: { preflight: false },
theme: {
extend: {
colors: {
'app-bg': 'var(--app-bg)',
'app-fg': 'var(--app-fg)',
'card-bg': 'var(--card-bg)',
'link': 'var(--link-color)',
'link-hover': 'var(--link-color-hover)',
'logo-bg': 'var(--app-logo-bg)',
},
maxWidth: {
'app': 'var(--app-maxWidth)',
},
borderRadius: {
'card': 'var(--card-radius)',
},
fontFamily: {
'sans': ['Inter', 'Roboto', 'Arial', 'sans-serif'],
},
spacing: {
'fluid-sm': 'clamp(0.5rem, 2vw, 1rem)',
'fluid-md': 'clamp(1rem, 3vw, 1.5rem)',
'fluid-lg': 'clamp(1rem, 4vw, 2rem)',
},
screens: {
'mobile': { 'max': '760px' },
'sm-dialog': { 'max': '600px' },
'tablet': { 'max': '900px' },
},
},
},
};

View File

@@ -5,7 +5,9 @@
"compilerOptions": {
"resolveJsonModule": true,
"outDir": "./out-tsc/app",
"types": []
"types": [
"@webgpu/types"
]
},
"include": [
"src/**/*.ts"

View File

@@ -5,7 +5,8 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
"jasmine",
"@webgpu/types"
]
},
"include": [