feat(app): add prerender, self-hosted fonts and privacy page

Fonts come from npm, not Google, so no third-party font requests.
Pages are prerendered at build time; browser-only code is guarded.
nginx serves per-route HTML, a real 404 and gzip.
This commit is contained in:
lobo committed 2026-10-20 15:43:31 +02:00
1 parent 57f861510e
commit 54b93055ba
42 files changed
+544 -88

No files matched your search

+3
View File
@@ -66,6 +66,9 @@ jobs:
- name: Build Production - name: Build Production
run: npx ng build --configuration production run: npx ng build --configuration production
- name: Prerender Check
run: npm run prerender:check
# 5. Lighthouse Audit (Performance & SEO) # 5. Lighthouse Audit (Performance & SEO)
- name: Lighthouse CI - name: Lighthouse CI
run: | run: |
+2 -2
View File
@@ -6,7 +6,7 @@ multi-language support (EN/DE) via **ngx-translate**.
The app is built as a static **Single Page Application (SPA)** served by **NGINX**, The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
deployed at: deployed at:
👉 [https://app.andreas-dahm.eu](https://app.andreas-dahm.eu) 👉 [https://andreas-dahm.eu](https://andreas-dahm.eu)
--- ---
@@ -138,7 +138,7 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
✅ Live site at https://app.andreas-dahm.eu ✅ Live site at https://andreas-dahm.eu
--- ---
+1 -1
View File
@@ -10,7 +10,7 @@ Open ideas for the playground frontend. Tick a box when done.
Note: the `@lhci/cli` → lighthouse 13 override in `package.json` is only safe without `assert`. Check it again before you add one. Note: the `@lhci/cli` → lighthouse 13 override in `package.json` is only safe without `assert`. Check it again before you add one.
- [ ] **robots.txt + sitemap.xml**: static files in `public/`, listing all routes from `app.routes.ts`. - [ ] **robots.txt + sitemap.xml**: static files in `public/`, listing all routes from `app.routes.ts`.
- [ ] **SEO per algorithm page**: all algorithm routes use `data: { seo: 'ALGORITHMS' }`. Give each one its own key and add `SEO.<KEY>.TITLE` / `DESCRIPTION` to `en.json` and `de.json`. - [ ] **SEO per algorithm page**: all algorithm routes use `data: { seo: 'ALGORITHMS' }`. Give each one its own key and add `SEO.<KEY>.TITLE` / `DESCRIPTION` to `en.json` and `de.json`.
- [ ] **nginx security headers + gzip**: no CSP, `X-Content-Type-Options`, `Referrer-Policy`, `frame-ancestors` and no `gzip` in `nginx.conf`. First check with `curl -I https://app.andreas-dahm.eu/` if the reverse proxy already sets them. - [ ] **nginx security headers**: no CSP, `X-Content-Type-Options`, `Referrer-Policy`, `frame-ancestors` in `nginx.conf` (gzip done). First check with `curl -I https://andreas-dahm.eu/` if the reverse proxy already sets them.
## Tests ## Tests
+14
View File
@@ -41,6 +41,10 @@
} }
], ],
"styles": [ "styles": [
"node_modules/@fontsource/roboto/300.css",
"node_modules/@fontsource/roboto/400.css",
"node_modules/@fontsource/roboto/500.css",
"node_modules/material-icons/iconfont/filled.css",
"src/tailwind.css", "src/tailwind.css",
"src/styles.scss", "src/styles.scss",
"node_modules/swiper/swiper-bundle.css" "node_modules/swiper/swiper-bundle.css"
@@ -60,7 +64,17 @@
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
},
"outputHashing": "all", "outputHashing": "all",
"outputMode": "static",
"server": "src/main.server.ts",
"serviceWorker": "ngsw-config.json" "serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
+1
View File
@@ -2,6 +2,7 @@
"ci": { "ci": {
"collect": { "collect": {
"numberOfRuns": 1, "numberOfRuns": 1,
"autodiscoverUrlBlocklist": ["/index.csr.html"],
"staticDistDir": "./dist/playground-frontend/browser", "staticDistDir": "./dist/playground-frontend/browser",
"settings": { "settings": {
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage" "chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
+19 -4
View File
@@ -3,6 +3,11 @@ server {
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json application/manifest+json image/svg+xml text/plain;
# index.html: never cache # index.html: never cache
location = /index.html { location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
@@ -41,6 +46,12 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Self-hosted fonts, content-hashed by the build
location ^~ /media/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Assets (Bilder, JSON, Fonts, etc.): just revalidate # Assets (Bilder, JSON, Fonts, etc.): just revalidate
location ^~ /assets/ { location ^~ /assets/ {
add_header Cache-Control "no-cache, max-age=0, must-revalidate"; add_header Cache-Control "no-cache, max-age=0, must-revalidate";
@@ -49,13 +60,17 @@ server {
# Also no cache for other html site # Also no cache for other html site
location ~* \.html$ { location ~* \.html$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache"; add_header Pragma "no-cache" always;
add_header Expires "0"; add_header Expires "0" always;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
try_files $uri $uri/ /index.html; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
# Prerendered pages live at <route>/index.html. Unknown URLs get the CSR shell
# with status 404 (error_page without "=" keeps the status).
try_files $uri $uri/index.html =404;
error_page 404 /index.csr.html;
} }
} }
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "./node_modules/@angular/service-worker/config/schema.json", "$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html", "index": "/index.csr.html",
"assetGroups": [ "assetGroups": [
{ {
"name": "app", "name": "app",
@@ -9,6 +9,7 @@
"files": [ "files": [
"/favicon.ico", "/favicon.ico",
"/index.html", "/index.html",
"/index.csr.html",
"/manifest.webmanifest", "/manifest.webmanifest",
"/*.css", "/*.css",
"/*.js" "/*.js"
@@ -22,6 +23,7 @@
"resources": { "resources": {
"files": [ "files": [
"/assets/**", "/assets/**",
"/media/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
] ]
} }
+69 -16
View File
@@ -17,11 +17,15 @@
"@angular/forms": "~22.2.2", "@angular/forms": "~22.2.2",
"@angular/material": "~22.2.2", "@angular/material": "~22.2.2",
"@angular/platform-browser": "~22.2.2", "@angular/platform-browser": "~22.2.2",
"@angular/platform-server": "~22.2.2",
"@angular/router": "~22.2.2", "@angular/router": "~22.2.2",
"@angular/service-worker": "~22.2.2", "@angular/service-worker": "~22.2.2",
"@angular/ssr": "~22.2.2",
"@babylonjs/core": "^9.30.0", "@babylonjs/core": "^9.30.0",
"@fontsource/roboto": "^5.3.0",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"swiper": "^14.3.0", "swiper": "^14.3.0",
"tslib": "~2.8.1" "tslib": "~2.8.1"
@@ -1117,6 +1121,26 @@
} }
} }
}, },
"node_modules/@angular/platform-server": {
"version": "22.2.2",
"resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-22.2.2.tgz",
"integrity": "sha512-CBqkqtkpIkEAtKJBRSplgBlWzNe2CMsbIZ4FFOPh9uHzBTmztA/D/QXqZKgnHLSP79FUmGjzrBizpk8fo9oVSg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0",
"xhr2": "^0.2.0"
},
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/common": "22.2.2",
"@angular/compiler": "22.2.2",
"@angular/core": "22.2.2",
"@angular/platform-browser": "22.2.2",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "22.2.2", "version": "22.2.2",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.2.tgz",
@@ -1154,6 +1178,27 @@
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/ssr": {
"version": "22.2.2",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-22.2.2.tgz",
"integrity": "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg==",
"license": "MIT",
"dependencies": {
"beasties": "0.5.4",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/platform-server": "^22.0.0",
"@angular/router": "^22.0.0"
},
"peerDependenciesMeta": {
"@angular/platform-server": {
"optional": true
}
}
},
"node_modules/@apm-js-collab/code-transformer": { "node_modules/@apm-js-collab/code-transformer": {
"version": "0.18.1", "version": "0.18.1",
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz",
@@ -2062,6 +2107,15 @@
} }
} }
}, },
"node_modules/@fontsource/roboto": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz",
"integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formatjs/ecma402-abstract": { "node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
@@ -5888,7 +5942,6 @@
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz", "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz",
"integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==", "integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"css-select": "^7.0.0", "css-select": "^7.0.0",
@@ -5990,7 +6043,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
@@ -6607,7 +6659,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"boolbase": "^2.0.0", "boolbase": "^2.0.0",
@@ -6642,7 +6693,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
@@ -6823,7 +6873,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"domelementtype": "^3.0.0", "domelementtype": "^3.0.0",
@@ -6842,7 +6891,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6858,7 +6906,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"domelementtype": "^3.0.0" "domelementtype": "^3.0.0"
@@ -6875,7 +6922,6 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"dom-serializer": "^3.0.0", "dom-serializer": "^3.0.0",
@@ -8045,7 +8091,6 @@
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"dev": true,
"funding": [ "funding": [
"https://github.com/fb55/htmlparser2?sponsor=1", "https://github.com/fb55/htmlparser2?sponsor=1",
{ {
@@ -9543,6 +9588,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/material-icons": {
"version": "1.13.14",
"resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
"integrity": "sha512-kZOfc7xCC0rAT8Q3DQixYAeT+tBqZnxkseQtp2bxBxz7q5pMAC+wmit7vJn1g/l7wRU+HEPq23gER4iPjGs5Cg==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -9784,7 +9835,6 @@
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9900,7 +9950,6 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"boolbase": "^2.0.0" "boolbase": "^2.0.0"
@@ -10330,7 +10379,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -10363,7 +10411,6 @@
"version": "8.5.29", "version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10392,14 +10439,12 @@
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/postcss-safe-parser": { "node_modules/postcss-safe-parser": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz",
"integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -11468,7 +11513,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -12793,6 +12837,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/xhr2": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
"integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+6 -1
View File
@@ -9,7 +9,8 @@
"test": "ng test --watch=false", "test": "ng test --watch=false",
"lint": "ng lint && npm run assets:check", "lint": "ng lint && npm run assets:check",
"i18n:check": "node scripts/check-i18n.mjs", "i18n:check": "node scripts/check-i18n.mjs",
"assets:check": "node scripts/check-assets.mjs" "assets:check": "node scripts/check-assets.mjs",
"prerender:check": "node scripts/check-prerender.mjs"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -22,11 +23,15 @@
"@angular/forms": "~22.2.2", "@angular/forms": "~22.2.2",
"@angular/material": "~22.2.2", "@angular/material": "~22.2.2",
"@angular/platform-browser": "~22.2.2", "@angular/platform-browser": "~22.2.2",
"@angular/platform-server": "~22.2.2",
"@angular/router": "~22.2.2", "@angular/router": "~22.2.2",
"@angular/service-worker": "~22.2.2", "@angular/service-worker": "~22.2.2",
"@angular/ssr": "~22.2.2",
"@babylonjs/core": "^9.30.0", "@babylonjs/core": "^9.30.0",
"@fontsource/roboto": "^5.3.0",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"swiper": "^14.3.0", "swiper": "^14.3.0",
"tslib": "~2.8.1" "tslib": "~2.8.1"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Verifies the production build: every route has prerendered HTML with its own
// canonical and og:url, the CSR shell exists, and no Google font host is left.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = join(root, 'dist', 'playground-frontend', 'browser');
const ORIGIN = 'https://andreas-dahm.eu';
const FORBIDDEN_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com'];
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(path);
} else {
yield path;
}
}
}
function routePaths() {
const constants = readFileSync(join(root, 'src', 'app', 'constants', 'RouterConstants.ts'), 'utf8');
const paths = [...constants.matchAll(/PATH: '([^']+)'/g)].map(match => match[1]);
return ['', ...paths];
}
function checkRoute(route, failures) {
const file = join(distDir, route, 'index.html');
if (!existsSync(file)) {
failures.push(`missing ${file}`);
return;
}
const html = readFileSync(file, 'utf8');
const url = `${ORIGIN}/${route}`;
const label = route || '/';
if (!html.includes(`rel="canonical" href="${url}"`) && !html.includes(`href="${url}" rel="canonical"`)) {
failures.push(`${label}: canonical is not ${url}`);
}
if (!html.includes(`property="og:url" content="${url}"`) && !html.includes(`content="${url}" property="og:url"`)) {
failures.push(`${label}: og:url is not ${url}`);
}
if (!/<title>[^<]+<\/title>/.test(html)) {
failures.push(`${label}: empty <title>`);
}
}
function checkForbiddenHosts(failures) {
for (const file of walk(distDir)) {
const content = readFileSync(file, 'latin1');
for (const host of FORBIDDEN_HOSTS) {
if (content.includes(host)) {
failures.push(`${file} references ${host}`);
}
}
}
}
if (!existsSync(distDir)) {
console.error(`Build output not found: ${distDir}`);
process.exit(1);
}
const failures = [];
routePaths().forEach(route => checkRoute(route, failures));
if (!existsSync(join(distDir, 'index.csr.html'))) {
failures.push('missing index.csr.html');
}
checkForbiddenHosts(failures);
if (failures.length > 0) {
console.error(`Prerender check failed:\n${failures.join('\n')}`);
process.exit(1);
}
console.log('Prerender check passed.');
+8
View File
@@ -0,0 +1,8 @@
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
export const serverConfig = mergeApplicationConfig(appConfig, {
providers: [provideServerRendering(withRoutes(serverRoutes))],
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import {provideAnimations} from '@angular/platform-browser/animations'; import {provideAnimations} from '@angular/platform-browser/animations';
import {provideHttpClient, withXhr} from '@angular/common/http'; import {provideHttpClient, withFetch} from '@angular/common/http';
import {provideServiceWorker} from '@angular/service-worker'; import {provideServiceWorker} from '@angular/service-worker';
import {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader'; import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
@@ -25,7 +25,7 @@ export const appConfig: ApplicationConfig = {
anchorScrolling: 'enabled' anchorScrolling: 'enabled'
})), })),
provideAnimations(), provideAnimations(),
provideHttpClient(withXhr()), provideHttpClient(withFetch()),
provideTranslateService({ provideTranslateService({
lang: INITIAL_LANG, lang: INITIAL_LANG,
fallbackLang: 'en', fallbackLang: 'en',
+21
View File
@@ -0,0 +1,21 @@
import { RenderMode } from '@angular/ssr';
import { routes } from './app.routes';
import { serverRoutes } from './app.routes.server';
describe('serverRoutes', () => {
it('prerenders every static route', () => {
const staticPaths = routes.map(route => route.path).filter(path => path !== '**');
const prerendered = serverRoutes
.filter(route => route.renderMode === RenderMode.Prerender)
.map(route => route.path);
expect(prerendered).toEqual(staticPaths);
});
it('renders the catch-all on the client', () => {
const catchAll = serverRoutes.find(route => route.path === '**');
expect(catchAll?.renderMode).toBe(RenderMode.Client);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
import { routes } from './app.routes';
const WILDCARD = '**';
/** Every static route gets its own HTML at build time; the catch-all stays client-rendered (it cannot be prerendered without params). */
export const serverRoutes: ServerRoute[] = [
...routes
.filter(route => route.path !== WILDCARD)
.map((route): ServerRoute => ({ path: route.path ?? '', renderMode: RenderMode.Prerender })),
{ path: WILDCARD, renderMode: RenderMode.Client },
];
+1
View File
@@ -9,6 +9,7 @@ export const routes: Routes = [
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } }, { path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
{ path: RouterConstants.PRIVACY.PATH, loadComponent: () => import('./pages/privacy/privacy.component').then(m => m.PrivacyComponent), data: { seo: 'PRIVACY' } },
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
+5
View File
@@ -70,6 +70,11 @@
LINK: '/imprint', LINK: '/imprint',
}; };
static readonly PRIVACY = {
PATH: 'privacy',
LINK: '/privacy',
};
static readonly STOPWATCH = { static readonly STOPWATCH = {
PATH: 'stopwatch', PATH: 'stopwatch',
LINK: '/stopwatch', LINK: '/stopwatch',
+7
View File
@@ -11,4 +11,11 @@
{{ 'APP.AI_NOTICE' | translate }} {{ 'APP.AI_NOTICE' | translate }}
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a> <a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
</small> </small>
<nav class="mt-2 flex justify-center gap-2" aria-label="Legal">
<a [routerLink]="RouterConstants.IMPRINT.LINK">{{ 'TOPBAR.IMPRINT' | translate }}</a>
<span aria-hidden="true">·</span>
<a [routerLink]="RouterConstants.PRIVACY.LINK">{{ 'PRIVACY.TITLE' | translate }}</a>
<span aria-hidden="true">·</span>
<a [routerLink]="RouterConstants.STOPWATCH.LINK">{{ 'TOPBAR.STOPWATCH' | translate }}</a>
</nav>
</footer> </footer>
+14 -3
View File
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core'; import { Component, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core'; import { provideTranslateService } from '@ngx-translate/core';
@@ -14,7 +14,9 @@ class TopbarStub {}
class ParticlesStub {} class ParticlesStub {}
describe('AppComponent', () => { describe('AppComponent', () => {
it('renders the copyright line with year, name and a closing dot', async () => { let fixture: ComponentFixture<AppComponent>;
beforeEach(async () => {
TestBed.overrideComponent(AppComponent, { TestBed.overrideComponent(AppComponent, {
remove: { imports: [TopbarComponent, ParticleBackgroundComponent] }, remove: { imports: [TopbarComponent, ParticleBackgroundComponent] },
add: { imports: [TopbarStub, ParticlesStub] }, add: { imports: [TopbarStub, ParticlesStub] },
@@ -29,10 +31,19 @@ describe('AppComponent', () => {
], ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(AppComponent); fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
});
it('renders the copyright line with year, name and a closing dot', () => {
const text = fixture.nativeElement.querySelector('footer small').textContent.trim(); const text = fixture.nativeElement.querySelector('footer small').textContent.trim();
expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`); expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`);
}); });
it('links imprint, privacy and stopwatch from the footer', () => {
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('footer nav a'))
.map(anchor => anchor.getAttribute('href'));
expect(hrefs).toEqual(['/imprint', '/privacy', '/stopwatch']);
});
}); });
+2
View File
@@ -4,6 +4,7 @@ import {TopbarComponent} from '../topbar/topbar.component';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component'; import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
import {SeoService} from '../../service/seo.service'; import {SeoService} from '../../service/seo.service';
import {RouterConstants} from '../../constants/RouterConstants';
@Component({ @Component({
@@ -14,6 +15,7 @@ import {SeoService} from '../../service/seo.service';
}) })
export class AppComponent { export class AppComponent {
private readonly seo = inject(SeoService); private readonly seo = inject(SeoService);
protected readonly RouterConstants = RouterConstants;
currentYear = new Date().getFullYear(); currentYear = new Date().getFullYear();
constructor() { constructor() {
@@ -12,10 +12,6 @@
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a> [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-xs 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> [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-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-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> </nav>
<!-- Mobile nav menu button --> <!-- Mobile nav menu button -->
@@ -36,12 +32,6 @@
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK"> <button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
{{ 'TOPBAR.ALGORITHMS' | translate }} {{ 'TOPBAR.ALGORITHMS' | translate }}
</button> </button>
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
{{ 'TOPBAR.STOPWATCH' | translate }}
</button>
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
{{ 'TOPBAR.IMPRINT' | translate }}
</button>
</mat-menu> </mat-menu>
<span class="flex-1"></span> <span class="flex-1"></span>
@@ -0,0 +1,30 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { TopbarComponent } from './topbar.component';
describe('TopbarComponent', () => {
let fixture: ComponentFixture<TopbarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TopbarComponent],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
],
}).compileComponents();
fixture = TestBed.createComponent(TopbarComponent);
fixture.detectChanges();
});
it('links only the main sections; imprint and stopwatch live in the footer', () => {
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('nav a'))
.map(anchor => anchor.getAttribute('href'));
expect(hrefs).toEqual(['/about', '/projects', '/algorithms']);
});
});
+1 -4
View File
@@ -24,10 +24,7 @@
</div> </div>
<div class="flex items-center flex-wrap gap-[0.4rem]"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">email</mat-icon> <mat-icon aria-hidden="true">email</mat-icon>
<button type="button" class="link-button" data-testid="contact-mail" <a href="mailto:info@andreas-dahm.eu" data-testid="contact-mail">info&#64;andreas-dahm.eu</a>
(click)="SharedFunctions.openMail($event)">
{{ 'ABOUT.CONTACT_ME' | translate }}
</button>
</div> </div>
<div class="flex items-center flex-wrap gap-[0.4rem]"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">data_object</mat-icon> <mat-icon aria-hidden="true">data_object</mat-icon>
+5 -2
View File
@@ -204,13 +204,16 @@ describe('AboutComponent', () => {
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0); expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
}); });
it('exposes the mail contact as a button rather than an empty link', () => { it('renders the mail contact as a mailto link that shows the address', () => {
const emptyLinks = fixture.debugElement const emptyLinks = fixture.debugElement
.queryAll(By.css('a')) .queryAll(By.css('a'))
.filter(anchor => anchor.nativeElement.getAttribute('href') === ''); .filter(anchor => anchor.nativeElement.getAttribute('href') === '');
expect(emptyLinks.length).toBe(0); expect(emptyLinks.length).toBe(0);
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
const mail = fixture.debugElement.query(By.css('a[data-testid="contact-mail"]')).nativeElement;
expect(mail.getAttribute('href')).toBe('mailto:info@andreas-dahm.eu');
expect(mail.textContent.trim()).toBe('info@andreas-dahm.eu');
}); });
}); });
-2
View File
@@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core';
import {UrlConstants} from '../../constants/UrlConstants'; import {UrlConstants} from '../../constants/UrlConstants';
import {AssetsConstants} from '../../constants/AssetsConstants'; import {AssetsConstants} from '../../constants/AssetsConstants';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
import {SharedFunctions} from '../../shared/SharedFunctions';
/** One role. Consecutive entries sharing a `company` render as a single employer block. */ /** One role. Consecutive entries sharing a `company` render as a single employer block. */
interface ExperienceEntry { interface ExperienceEntry {
@@ -267,5 +266,4 @@ export class AboutComponent {
protected readonly UrlConstants = UrlConstants; protected readonly UrlConstants = UrlConstants;
protected readonly AssetsConstants = AssetsConstants; protected readonly AssetsConstants = AssetsConstants;
protected readonly SharedFunctions = SharedFunctions;
} }
@@ -1,5 +1,5 @@
import {AfterViewInit, Component, ElementRef, inject, signal, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule, isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card'; import {MatCardModule} from '@angular/material/card';
@@ -33,6 +33,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
}) })
export class FourColorComponent implements AfterViewInit { export class FourColorComponent implements AfterViewInit {
private readonly fourColorService = inject(FourColorService); private readonly fourColorService = inject(FourColorService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
@@ -70,6 +71,9 @@ export class FourColorComponent implements AfterViewInit {
private nodeSize = 0; private nodeSize = 0;
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.canvasRef.nativeElement.getContext('2d')!; this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.initializeGrid(); this.initializeGrid();
} }
@@ -1,4 +1,5 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
@@ -32,6 +33,7 @@ import {
}) })
export class FourierComponent implements AfterViewInit, OnDestroy { export class FourierComponent implements AfterViewInit, OnDestroy {
private readonly fourierService = inject(FourierService); private readonly fourierService = inject(FourierService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly themeService = inject(ThemeService); private readonly themeService = inject(ThemeService);
private readonly ngZone = inject(NgZone); private readonly ngZone = inject(NgZone);
@@ -75,6 +77,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
protected readonly MAX_SPEED = MAX_SPEED; protected readonly MAX_SPEED = MAX_SPEED;
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.canvasRef.nativeElement.getContext('2d')!; this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.resizeCanvas(); this.resizeCanvas();
this.applyPreset(Preset.HEART); this.applyPreset(Preset.HEART);
@@ -84,6 +89,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (!this.isBrowser) {
return;
}
cancelAnimationFrame(this.animationFrameId); cancelAnimationFrame(this.animationFrameId);
} }
+1 -5
View File
@@ -1,7 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import {MatCard} from '@angular/material/card'; import {MatCard} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {SharedFunctions} from '../../shared/SharedFunctions';
@Component({ @Component({
selector: 'app-imprint', selector: 'app-imprint',
@@ -12,7 +11,4 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
templateUrl: './imprint.component.html', templateUrl: './imprint.component.html',
styleUrl: './imprint.component.scss', styleUrl: './imprint.component.scss',
}) })
export class ImprintComponent { export class ImprintComponent {}
protected readonly SharedFunctions = SharedFunctions;
}
@@ -0,0 +1,28 @@
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="p-fluid-md">
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'PRIVACY.TITLE' | translate }}</h2>
<div class="grid gap-1 mb-4">
<h3 class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'PRIVACY.CONTROLLER' | translate }}</h3>
<p>
Andreas Dahm<br />
Hauptstraße 37A<br />
85614 Kirchseeon<br />
{{ 'IMPRINT.COUNTRY' | translate }}
</p>
<p>
E-Mail:
<a href="mailto:info&#64;andreas-dahm&#46;eu">
info&#64;andreas-dahm&#46;eu
</a>
</p>
</div>
@for (key of sectionKeys; track key) {
<div class="grid gap-1 mb-4">
<h3 class="text-xs tracking-[0.04em] uppercase opacity-70 m-0" data-testid="privacy-section">{{ key + '.TITLE' | translate }}</h3>
<p>{{ key + '.TEXT' | translate }}</p>
</div>
}
</mat-card>
</section>
@@ -0,0 +1,69 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideTranslateService } from '@ngx-translate/core';
import { PrivacyComponent } from './privacy.component';
import { routes } from '../../app.routes';
import en from '../../../assets/i18n/en.json';
import de from '../../../assets/i18n/de.json';
describe('PrivacyComponent', () => {
let fixture: ComponentFixture<PrivacyComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PrivacyComponent],
providers: [
provideZonelessChangeDetection(),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
],
}).compileComponents();
fixture = TestBed.createComponent(PrivacyComponent);
fixture.detectChanges();
});
it('renders the seven sections in the documented order', () => {
const headings = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('[data-testid="privacy-section"]'))
.map(heading => heading.textContent?.trim());
expect(headings).toEqual([
'PRIVACY.SECTIONS.HOSTING.TITLE',
'PRIVACY.SECTIONS.STORAGE.TITLE',
'PRIVACY.SECTIONS.SERVICE_WORKER.TITLE',
'PRIVACY.SECTIONS.FONTS.TITLE',
'PRIVACY.SECTIONS.EXTERNAL_LINKS.TITLE',
'PRIVACY.SECTIONS.RIGHTS.TITLE',
'PRIVACY.SECTIONS.AUTHORITY.TITLE',
]);
});
it('has every key it uses, plus the SEO keys, in en and de', () => {
const keys = [
'PRIVACY.TITLE',
'PRIVACY.CONTROLLER',
'SEO.PRIVACY.TITLE',
'SEO.PRIVACY.DESCRIPTION',
...fixture.componentInstance.sectionKeys.flatMap(key => [`${key}.TITLE`, `${key}.TEXT`]),
];
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
keys
.filter(key => typeof resolve(translations, key) !== 'string')
.map(key => `${language}: ${key}`)
);
expect(missing).toEqual([]);
});
it('is routed at /privacy with its own SEO entry', () => {
const route = routes.find(entry => entry.path === 'privacy');
expect(route?.data?.['seo']).toBe('PRIVACY');
});
function resolve(translations: unknown, key: string): unknown {
return key
.split('.')
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
}
});
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { MatCard } from '@angular/material/card';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-privacy',
imports: [MatCard, TranslatePipe],
templateUrl: './privacy.component.html',
})
export class PrivacyComponent {
/** Each key has TITLE and TEXT in i18n. */
readonly sectionKeys = ['HOSTING', 'STORAGE', 'SERVICE_WORKER', 'FONTS', 'EXTERNAL_LINKS', 'RIGHTS', 'AUTHORITY']
.map(section => `PRIVACY.SECTIONS.${section}`);
}
+6 -2
View File
@@ -1,4 +1,5 @@
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit } from '@angular/core'; import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { MatCardModule } from "@angular/material/card"; import { MatCardModule } from "@angular/material/card";
@@ -49,6 +50,7 @@ export interface Projects {
export class ProjectsComponent implements OnInit, OnDestroy { export class ProjectsComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly router = inject(Router); private readonly router = inject(Router);
private queryParamSub: Subscription | undefined; private queryParamSub: Subscription | undefined;
@@ -283,7 +285,9 @@ export class ProjectsComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
window.scrollTo({ top: 0, behavior: 'smooth' }); if (this.isBrowser) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
setTimeout(() => { this.dialogOpenFunction(); }, 10); setTimeout(() => { this.dialogOpenFunction(); }, 10);
} }
-7
View File
@@ -1,12 +1,5 @@
export class SharedFunctions { export class SharedFunctions {
static openMail(event?: Event) {
event?.preventDefault();
const user = 'info';
const domain = 'andreas-dahm.eu';
globalThis.location.href = `mailto:${user}@${domain}`;
}
static randomIntFromInterval(min: number, max: number): number { static randomIntFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min); return Math.floor(Math.random() * (max - min + 1) + min);
} }
@@ -1,5 +1,5 @@
import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, Output, PLATFORM_ID, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule, isPlatformBrowser} from '@angular/common';
export interface GridPos { row: number; col: number } export interface GridPos { row: number; col: number }
@@ -31,6 +31,7 @@ export class GenericGridComponent implements AfterViewInit {
@Output() gridChange = new EventEmitter<any[][]>(); @Output() gridChange = new EventEmitter<any[][]>();
@Output() nodeClick = new EventEmitter<GridPos>(); @Output() nodeClick = new EventEmitter<GridPos>();
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
grid: any[][] = []; grid: any[][] = [];
@@ -38,6 +39,9 @@ export class GenericGridComponent implements AfterViewInit {
protected lastCell: GridPos | null = null; protected lastCell: GridPos | null = null;
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.getContextOrThrow(); this.ctx = this.getContextOrThrow();
this.setupCanvasListeners(); this.setupCanvasListeners();
this.applyGridSize(); this.applyGridSize();
@@ -156,6 +160,9 @@ export class GenericGridComponent implements AfterViewInit {
} }
drawNode(node: any): void { drawNode(node: any): void {
if (!this.ctx) {
return;
}
this.ctx.fillStyle = this.getNodeColorFn(node); this.ctx.fillStyle = this.getNodeColorFn(node);
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize); this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
@@ -40,6 +40,9 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (!this.isBrowser) {
return;
}
cancelAnimationFrame(this.animationFrameId); cancelAnimationFrame(this.animationFrameId);
} }
@@ -1,4 +1,5 @@
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core'; import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
@@ -33,6 +34,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly gpuCapability = inject(GpuCapabilityService); private readonly gpuCapability = inject(GpuCapabilityService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -54,10 +56,16 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
private readonly wheelHandler = (evt: WheelEvent) => evt.preventDefault(); private readonly wheelHandler = (evt: WheelEvent) => evt.preventDefault();
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initBabylon().then(() => { console.log("Engine initialized"); }); this.initBabylon().then(() => { console.log("Engine initialized"); });
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (!this.isBrowser) {
return;
}
window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler);
const canvas = this.canvasRef?.nativeElement; const canvas = this.canvasRef?.nativeElement;
+38 -1
View File
@@ -33,7 +33,6 @@
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt", "ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
"LOCATION": "München · Remote", "LOCATION": "München · Remote",
"VIEW_PROJECTS": "Projekte ansehen", "VIEW_PROJECTS": "Projekte ansehen",
"CONTACT_ME": "Kontaktiere mich",
"SECTION": { "SECTION": {
"SKILLS": "Fähigkeiten & Stack", "SKILLS": "Fähigkeiten & Stack",
"LANGUAGES": "Sprachen & Frameworks", "LANGUAGES": "Sprachen & Frameworks",
@@ -416,6 +415,40 @@
"COUNTRY": "Deutschland", "COUNTRY": "Deutschland",
"CONTACT": "Kontakt" "CONTACT": "Kontakt"
}, },
"PRIVACY": {
"TITLE": "Datenschutzerklärung",
"CONTROLLER": "Verantwortlicher",
"SECTIONS": {
"HOSTING": {
"TITLE": "Hosting und Server-Logs",
"TEXT": "Diese Seite wird bei Hostinger gehostet [TODO: Rechtsträger, Adresse und Aufbewahrungsdauer der Logs bestätigen; Auftragsverarbeitungsvertrag (Art. 28 DSGVO) bestätigen]. Der Webserver schreibt Server-Logs: IP-Adresse, Zeitpunkt, aufgerufene URL und User-Agent. Zweck: Betrieb und Sicherheit der Seite. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO. Die Logs werden nach kurzer Zeit gelöscht [TODO: genaue Dauer]."
},
"STORAGE": {
"TITLE": "Lokaler Speicher",
"TEXT": "Die Seite speichert nur Sprache und Design (hell oder dunkel) im localStorage des Browsers. Es gibt keine Cookies, kein Tracking und keine Analyse-Tools."
},
"SERVICE_WORKER": {
"TITLE": "Service Worker",
"TEXT": "Ein Service Worker legt Dateien der Seite im Browser-Cache ab, damit die Seite schneller und offline lädt. Es werden keine Daten an Dritte gesendet."
},
"FONTS": {
"TITLE": "Schriften",
"TEXT": "Schriften (Roboto, Material Icons) werden vom eigenen Server geladen. Es gibt keine Verbindung zu Google."
},
"EXTERNAL_LINKS": {
"TITLE": "Externe Links",
"TEXT": "Links zu Gitea, LinkedIn und Codeberg werden erst beim Klick aufgerufen. Ab dann gilt die Datenschutzerklärung des jeweiligen Anbieters."
},
"RIGHTS": {
"TITLE": "Ihre Rechte",
"TEXT": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch (Art. 15 bis 21 DSGVO). Kontakt: die E-Mail-Adresse oben."
},
"AUTHORITY": {
"TITLE": "Beschwerderecht",
"TEXT": "Sie können sich bei einer Aufsichtsbehörde beschweren. Zuständig: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach."
}
}
},
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Pfadfindungsalgorithmen", "TITLE": "Pfadfindungsalgorithmen",
"START_NODE": "Startknoten", "START_NODE": "Startknoten",
@@ -738,6 +771,10 @@
"TITLE": "Impressum – Andreas Dahm", "TITLE": "Impressum – Andreas Dahm",
"DESCRIPTION": "Rechtliche Informationen und Kontaktdaten zur Website Andreas Dahm Playground." "DESCRIPTION": "Rechtliche Informationen und Kontaktdaten zur Website Andreas Dahm Playground."
}, },
"PRIVACY": {
"TITLE": "Datenschutzerklärung – Andreas Dahm",
"DESCRIPTION": "Datenschutzerklärung zur Website Andreas Dahm Playground."
},
"STOPWATCH": { "STOPWATCH": {
"TITLE": "Stoppuhr – Andreas Dahm", "TITLE": "Stoppuhr – Andreas Dahm",
"DESCRIPTION": "Eine einfache Online-Stoppuhr mit konfigurierbaren Intervall-Signaltönen." "DESCRIPTION": "Eine einfache Online-Stoppuhr mit konfigurierbaren Intervall-Signaltönen."
+38 -1
View File
@@ -33,7 +33,6 @@
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect", "ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
"LOCATION": "Munich · Remote", "LOCATION": "Munich · Remote",
"VIEW_PROJECTS": "View projects", "VIEW_PROJECTS": "View projects",
"CONTACT_ME": "Contact me",
"SECTION": { "SECTION": {
"SKILLS": "Skills & Stack", "SKILLS": "Skills & Stack",
"LANGUAGES": "Languages & Frameworks", "LANGUAGES": "Languages & Frameworks",
@@ -416,6 +415,40 @@
"COUNTRY": "Germany", "COUNTRY": "Germany",
"CONTACT": "Contact" "CONTACT": "Contact"
}, },
"PRIVACY": {
"TITLE": "Privacy policy",
"CONTROLLER": "Controller",
"SECTIONS": {
"HOSTING": {
"TITLE": "Hosting and server logs",
"TEXT": "This site is hosted by Hostinger [TODO: legal entity, address and log retention period to be confirmed; data processing agreement (Art. 28 GDPR) to be confirmed]. The web server writes server logs: IP address, time, requested URL and user agent. Purpose: operation and security of the site. Legal basis: Art. 6 (1) (f) GDPR. The logs are deleted after a short time [TODO: exact period]."
},
"STORAGE": {
"TITLE": "Local storage",
"TEXT": "The site stores only your language and theme (light or dark) in the local storage of your browser. There are no cookies, no tracking and no analytics tools."
},
"SERVICE_WORKER": {
"TITLE": "Service worker",
"TEXT": "A service worker keeps files of this site in the browser cache, so the site loads faster and works offline. No data is sent to third parties."
},
"FONTS": {
"TITLE": "Fonts",
"TEXT": "Fonts (Roboto, Material Icons) are loaded from this server. There is no connection to Google."
},
"EXTERNAL_LINKS": {
"TITLE": "External links",
"TEXT": "Links to Gitea, LinkedIn and Codeberg are only opened when you click them. From then on the privacy policy of the respective provider applies."
},
"RIGHTS": {
"TITLE": "Your rights",
"TEXT": "You have the right to access, rectification, erasure, restriction of processing, data portability and objection (Art. 15 to 21 GDPR). Contact: the e-mail address above."
},
"AUTHORITY": {
"TITLE": "Right to lodge a complaint",
"TEXT": "You can complain to a supervisory authority. Responsible: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach."
}
}
},
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Pathfinding Algorithms", "TITLE": "Pathfinding Algorithms",
"START_NODE": "Start Node", "START_NODE": "Start Node",
@@ -738,6 +771,10 @@
"TITLE": "Imprint – Andreas Dahm", "TITLE": "Imprint – Andreas Dahm",
"DESCRIPTION": "Legal information and contact details for the Andreas Dahm Playground website." "DESCRIPTION": "Legal information and contact details for the Andreas Dahm Playground website."
}, },
"PRIVACY": {
"TITLE": "Privacy policy – Andreas Dahm",
"DESCRIPTION": "Privacy policy for the Andreas Dahm Playground website."
},
"STOPWATCH": { "STOPWATCH": {
"TITLE": "Stopwatch – Andreas Dahm", "TITLE": "Stopwatch – Andreas Dahm",
"DESCRIPTION": "A simple online stopwatch with configurable interval beeps." "DESCRIPTION": "A simple online stopwatch with configurable interval beeps."
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+1 -6
View File
@@ -12,7 +12,7 @@
<meta name="theme-color" content="#313131"> <meta name="theme-color" content="#313131">
<link rel="canonical" href="https://andreas-dahm.eu/"> <link rel="canonical" href="https://andreas-dahm.eu/">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="assets/icons/app-icon.svg"> <link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="Andreas Dahm – Playground"> <meta property="og:site_name" content="Andreas Dahm – Playground">
@@ -34,11 +34,6 @@
fetch in parallel with the bundle instead of after it. fetch in parallel with the bundle instead of after it.
--> -->
<link rel="preload" as="image" href="assets/me.webp" fetchpriority="high"> <link rel="preload" as="image" href="assets/me.webp" fetchpriority="high">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root class="flex flex-col min-h-screen"></app-root> <app-root class="flex flex-col min-h-screen"></app-root>
+7
View File
@@ -0,0 +1,7 @@
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/layout/app/app.component';
import { serverConfig } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, serverConfig, context);
export default bootstrap;
+2 -2
View File
@@ -14,11 +14,11 @@
"src": "assets/icons/app-icon.svg", "src": "assets/icons/app-icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any maskable" "purpose": "any"
}, },
{ {
"src": "favicon.ico", "src": "favicon.ico",
"sizes": "16x16 29x32", "sizes": "16x16 32x32",
"type": "image/x-icon" "type": "image/x-icon"
} }
] ]
-10
View File
@@ -44,16 +44,6 @@
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px]; @apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
} }
/*
Preflight is off, so a bare <button> keeps the UA font (13.33px Arial) instead of
the page font. Anything that should read as a link but has to stay a button for
accessibility uses this.
*/
@utility link-button {
font: inherit;
@apply text-link font-medium bg-transparent p-0 cursor-pointer hover:text-link-hover hover:underline;
}
@utility card-gradient-bar { @utility card-gradient-bar {
@apply relative; @apply relative;