From 54b93055ba47edffdc04a985780a6b00a4c20962 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 20 Oct 2026 15:43:31 +0200 Subject: [PATCH] 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. --- .gitea/workflows/build-Frontend-a.yml | 3 + README.md | 4 +- ROADMAP.md | 2 +- angular.json | 14 +++ lighthouserc.json | 1 + nginx.conf | 23 ++++- ngsw-config.json | 4 +- package-lock.json | 85 ++++++++++++++---- package.json | 7 +- scripts/check-prerender.mjs | 78 ++++++++++++++++ src/app/app.config.server.ts | 8 ++ src/app/app.config.ts | 4 +- src/app/app.routes.server.spec.ts | 21 +++++ src/app/app.routes.server.ts | 12 +++ src/app/app.routes.ts | 1 + src/app/constants/RouterConstants.ts | 5 ++ src/app/layout/app/app.component.html | 7 ++ src/app/layout/app/app.component.spec.ts | 17 +++- src/app/layout/app/app.component.ts | 2 + src/app/layout/topbar/topbar.component.html | 10 --- .../layout/topbar/topbar.component.spec.ts | 30 +++++++ src/app/pages/about/about.component.html | 5 +- src/app/pages/about/about.component.spec.ts | 7 +- src/app/pages/about/about.component.ts | 2 - .../four-color/four-color.component.ts | 8 +- .../algorithms/fourier/fourier.component.ts | 10 ++- src/app/pages/imprint/imprint.component.ts | 6 +- src/app/pages/privacy/privacy.component.html | 28 ++++++ .../pages/privacy/privacy.component.spec.ts | 69 ++++++++++++++ src/app/pages/privacy/privacy.component.ts | 14 +++ src/app/pages/projects/projects.component.ts | 8 +- src/app/shared/SharedFunctions.ts | 7 -- .../components/generic-grid/generic-grid.ts | 11 ++- .../particles-background.component.ts | 3 + .../render-canvas/babylon-canvas.component.ts | 10 ++- src/assets/i18n/de.json | 39 +++++++- src/assets/i18n/en.json | 39 +++++++- src/assets/icons/apple-touch-icon.png | Bin 0 -> 2733 bytes src/index.html | 7 +- src/main.server.ts | 7 ++ src/manifest.webmanifest | 4 +- src/tailwind.css | 10 --- 42 files changed, 544 insertions(+), 88 deletions(-) create mode 100644 scripts/check-prerender.mjs create mode 100644 src/app/app.config.server.ts create mode 100644 src/app/app.routes.server.spec.ts create mode 100644 src/app/app.routes.server.ts create mode 100644 src/app/layout/topbar/topbar.component.spec.ts create mode 100644 src/app/pages/privacy/privacy.component.html create mode 100644 src/app/pages/privacy/privacy.component.spec.ts create mode 100644 src/app/pages/privacy/privacy.component.ts create mode 100644 src/assets/icons/apple-touch-icon.png create mode 100644 src/main.server.ts diff --git a/.gitea/workflows/build-Frontend-a.yml b/.gitea/workflows/build-Frontend-a.yml index 4ad2800..12ac1f0 100644 --- a/.gitea/workflows/build-Frontend-a.yml +++ b/.gitea/workflows/build-Frontend-a.yml @@ -66,6 +66,9 @@ jobs: - name: Build Production run: npx ng build --configuration production + - name: Prerender Check + run: npm run prerender:check + # 5. Lighthouse Audit (Performance & SEO) - name: Lighthouse CI run: | diff --git a/README.md b/README.md index 0b85db0..2b1f1ae 100644 --- a/README.md +++ b/README.md @@ -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**, 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 ``` -✅ Live site at https://app.andreas-dahm.eu +✅ Live site at https://andreas-dahm.eu --- diff --git a/ROADMAP.md b/ROADMAP.md index c5c02fb..2666ec4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. - [ ] **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..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 diff --git a/angular.json b/angular.json index 81aaf60..302ef8c 100644 --- a/angular.json +++ b/angular.json @@ -41,6 +41,10 @@ } ], "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/styles.scss", "node_modules/swiper/swiper-bundle.css" @@ -60,7 +64,17 @@ "maximumError": "8kB" } ], + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false + }, + "fonts": true + }, "outputHashing": "all", + "outputMode": "static", + "server": "src/main.server.ts", "serviceWorker": "ngsw-config.json" }, "development": { diff --git a/lighthouserc.json b/lighthouserc.json index c18a3b5..75636e5 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -2,6 +2,7 @@ "ci": { "collect": { "numberOfRuns": 1, + "autodiscoverUrlBlocklist": ["/index.csr.html"], "staticDistDir": "./dist/playground-frontend/browser", "settings": { "chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage" diff --git a/nginx.conf b/nginx.conf index 144660e..9edd21d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,6 +3,11 @@ server { server_name _; 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 location = /index.html { add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; @@ -41,6 +46,12 @@ server { 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 location ^~ /assets/ { add_header Cache-Control "no-cache, max-age=0, must-revalidate"; @@ -49,13 +60,17 @@ server { # Also no cache for other html site location ~* \.html$ { - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; - add_header Pragma "no-cache"; - add_header Expires "0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; try_files $uri =404; } location / { - try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; + # Prerendered pages live at /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; } } diff --git a/ngsw-config.json b/ngsw-config.json index 2972fd6..c3fb3e3 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", + "index": "/index.csr.html", "assetGroups": [ { "name": "app", @@ -9,6 +9,7 @@ "files": [ "/favicon.ico", "/index.html", + "/index.csr.html", "/manifest.webmanifest", "/*.css", "/*.js" @@ -22,6 +23,7 @@ "resources": { "files": [ "/assets/**", + "/media/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" ] } diff --git a/package-lock.json b/package-lock.json index 2c75c27..dbd42b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,15 @@ "@angular/forms": "~22.2.2", "@angular/material": "~22.2.2", "@angular/platform-browser": "~22.2.2", + "@angular/platform-server": "~22.2.2", "@angular/router": "~22.2.2", "@angular/service-worker": "~22.2.2", + "@angular/ssr": "~22.2.2", "@babylonjs/core": "^9.30.0", + "@fontsource/roboto": "^5.3.0", "@ngx-translate/core": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0", + "material-icons": "^1.13.14", "rxjs": "~7.8.2", "swiper": "^14.3.0", "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": { "version": "22.2.2", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.2.tgz", @@ -1154,6 +1178,27 @@ "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": { "version": "0.18.1", "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": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -5888,7 +5942,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz", "integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "css-select": "^7.0.0", @@ -5990,7 +6043,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", - "dev": true, "license": "ISC", "engines": { "node": ">=20.19.0" @@ -6607,7 +6659,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^2.0.0", @@ -6642,7 +6693,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -6823,7 +6873,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^3.0.0", @@ -6842,7 +6891,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", - "dev": true, "funding": [ { "type": "github", @@ -6858,7 +6906,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^3.0.0" @@ -6875,7 +6922,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^3.0.0", @@ -8045,7 +8091,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", - "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -9543,6 +9588,12 @@ "dev": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9784,7 +9835,6 @@ "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, "funding": [ { "type": "github", @@ -9900,7 +9950,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^2.0.0" @@ -10330,7 +10379,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10363,7 +10411,6 @@ "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10392,14 +10439,12 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11468,7 +11513,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12793,6 +12837,15 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index c385292..4d5ff23 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test": "ng test --watch=false", "lint": "ng lint && npm run assets:check", "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, "dependencies": { @@ -22,11 +23,15 @@ "@angular/forms": "~22.2.2", "@angular/material": "~22.2.2", "@angular/platform-browser": "~22.2.2", + "@angular/platform-server": "~22.2.2", "@angular/router": "~22.2.2", "@angular/service-worker": "~22.2.2", + "@angular/ssr": "~22.2.2", "@babylonjs/core": "^9.30.0", + "@fontsource/roboto": "^5.3.0", "@ngx-translate/core": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0", + "material-icons": "^1.13.14", "rxjs": "~7.8.2", "swiper": "^14.3.0", "tslib": "~2.8.1" diff --git a/scripts/check-prerender.mjs b/scripts/check-prerender.mjs new file mode 100644 index 0000000..9d08445 --- /dev/null +++ b/scripts/check-prerender.mjs @@ -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>/.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.'); diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts new file mode 100644 index 0000000..1a9937a --- /dev/null +++ b/src/app/app.config.server.ts @@ -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))], +}); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 9a1a5de..83630d7 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -3,7 +3,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { routes } from './app.routes'; 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 {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateHttpLoader} from '@ngx-translate/http-loader'; @@ -25,7 +25,7 @@ export const appConfig: ApplicationConfig = { anchorScrolling: 'enabled' })), provideAnimations(), - provideHttpClient(withXhr()), + provideHttpClient(withFetch()), provideTranslateService({ lang: INITIAL_LANG, fallbackLang: 'en', diff --git a/src/app/app.routes.server.spec.ts b/src/app/app.routes.server.spec.ts new file mode 100644 index 0000000..f38b87a --- /dev/null +++ b/src/app/app.routes.server.spec.ts @@ -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); + }); +}); diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts new file mode 100644 index 0000000..43f6830 --- /dev/null +++ b/src/app/app.routes.server.ts @@ -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 }, +]; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index db25c18..d0c14aa 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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.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.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.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' } }, diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index e44790b..154da69 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -70,6 +70,11 @@ LINK: '/imprint', }; + static readonly PRIVACY = { + PATH: 'privacy', + LINK: '/privacy', + }; + static readonly STOPWATCH = { PATH: 'stopwatch', LINK: '/stopwatch', diff --git a/src/app/layout/app/app.component.html b/src/app/layout/app/app.component.html index d6dfcd7..a6720b9 100644 --- a/src/app/layout/app/app.component.html +++ b/src/app/layout/app/app.component.html @@ -11,4 +11,11 @@ {{ 'APP.AI_NOTICE' | translate }} <a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a> </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> diff --git a/src/app/layout/app/app.component.spec.ts b/src/app/layout/app/app.component.spec.ts index e5641f7..2288e71 100644 --- a/src/app/layout/app/app.component.spec.ts +++ b/src/app/layout/app/app.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideTranslateService } from '@ngx-translate/core'; @@ -14,7 +14,9 @@ class TopbarStub {} class ParticlesStub {} describe('AppComponent', () => { - it('renders the copyright line with year, name and a closing dot', async () => { + let fixture: ComponentFixture<AppComponent>; + + beforeEach(async () => { TestBed.overrideComponent(AppComponent, { remove: { imports: [TopbarComponent, ParticleBackgroundComponent] }, add: { imports: [TopbarStub, ParticlesStub] }, @@ -29,10 +31,19 @@ describe('AppComponent', () => { ], }).compileComponents(); - const fixture = TestBed.createComponent(AppComponent); + fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); + }); + it('renders the copyright line with year, name and a closing dot', () => { const text = fixture.nativeElement.querySelector('footer small').textContent.trim(); 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']); + }); }); diff --git a/src/app/layout/app/app.component.ts b/src/app/layout/app/app.component.ts index b95bee8..dd3b2a0 100644 --- a/src/app/layout/app/app.component.ts +++ b/src/app/layout/app/app.component.ts @@ -4,6 +4,7 @@ import {TopbarComponent} from '../topbar/topbar.component'; import {TranslatePipe} from '@ngx-translate/core'; import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component'; import {SeoService} from '../../service/seo.service'; +import {RouterConstants} from '../../constants/RouterConstants'; @Component({ @@ -14,6 +15,7 @@ import {SeoService} from '../../service/seo.service'; }) export class AppComponent { private readonly seo = inject(SeoService); + protected readonly RouterConstants = RouterConstants; currentYear = new Date().getFullYear(); constructor() { diff --git a/src/app/layout/topbar/topbar.component.html b/src/app/layout/topbar/topbar.component.html index 705d763..0eb0a1a 100644 --- a/src/app/layout/topbar/topbar.component.html +++ b/src/app/layout/topbar/topbar.component.html @@ -12,10 +12,6 @@ [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" [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> <!-- Mobile nav menu button --> @@ -36,12 +32,6 @@ <button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK"> {{ 'TOPBAR.ALGORITHMS' | translate }} </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> <span class="flex-1"></span> diff --git a/src/app/layout/topbar/topbar.component.spec.ts b/src/app/layout/topbar/topbar.component.spec.ts new file mode 100644 index 0000000..86b6eba --- /dev/null +++ b/src/app/layout/topbar/topbar.component.spec.ts @@ -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']); + }); +}); diff --git a/src/app/pages/about/about.component.html b/src/app/pages/about/about.component.html index aeb714f..ad2bf37 100644 --- a/src/app/pages/about/about.component.html +++ b/src/app/pages/about/about.component.html @@ -24,10 +24,7 @@ </div> <div class="flex items-center flex-wrap gap-[0.4rem]"> <mat-icon aria-hidden="true">email</mat-icon> - <button type="button" class="link-button" data-testid="contact-mail" - (click)="SharedFunctions.openMail($event)"> - {{ 'ABOUT.CONTACT_ME' | translate }} - </button> + <a href="mailto:info@andreas-dahm.eu" data-testid="contact-mail">info@andreas-dahm.eu</a> </div> <div class="flex items-center flex-wrap gap-[0.4rem]"> <mat-icon aria-hidden="true">data_object</mat-icon> diff --git a/src/app/pages/about/about.component.spec.ts b/src/app/pages/about/about.component.spec.ts index 40be488..9ef0d75 100644 --- a/src/app/pages/about/about.component.spec.ts +++ b/src/app/pages/about/about.component.spec.ts @@ -204,13 +204,16 @@ describe('AboutComponent', () => { 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 .queryAll(By.css('a')) .filter(anchor => anchor.nativeElement.getAttribute('href') === ''); 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'); }); }); diff --git a/src/app/pages/about/about.component.ts b/src/app/pages/about/about.component.ts index 2b1c004..1333a42 100644 --- a/src/app/pages/about/about.component.ts +++ b/src/app/pages/about/about.component.ts @@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import {UrlConstants} from '../../constants/UrlConstants'; import {AssetsConstants} from '../../constants/AssetsConstants'; import {RouterLink} from '@angular/router'; -import {SharedFunctions} from '../../shared/SharedFunctions'; /** One role. Consecutive entries sharing a `company` render as a single employer block. */ interface ExperienceEntry { @@ -267,5 +266,4 @@ export class AboutComponent { protected readonly UrlConstants = UrlConstants; protected readonly AssetsConstants = AssetsConstants; - protected readonly SharedFunctions = SharedFunctions; } diff --git a/src/app/pages/algorithms/four-color/four-color.component.ts b/src/app/pages/algorithms/four-color/four-color.component.ts index a459964..d893258 100644 --- a/src/app/pages/algorithms/four-color/four-color.component.ts +++ b/src/app/pages/algorithms/four-color/four-color.component.ts @@ -1,5 +1,5 @@ -import {AfterViewInit, Component, ElementRef, inject, signal, ViewChild} from '@angular/core'; -import {CommonModule} from '@angular/common'; +import {AfterViewInit, Component, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core'; +import {CommonModule, isPlatformBrowser} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatCardModule} from '@angular/material/card'; @@ -33,6 +33,7 @@ import {UrlConstants} from '../../../constants/UrlConstants'; }) export class FourColorComponent implements AfterViewInit { private readonly fourColorService = inject(FourColorService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly translate = inject(TranslateService); private readonly snackBar = inject(MatSnackBar); @@ -70,6 +71,9 @@ export class FourColorComponent implements AfterViewInit { private nodeSize = 0; ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.ctx = this.canvasRef.nativeElement.getContext('2d')!; this.initializeGrid(); } diff --git a/src/app/pages/algorithms/fourier/fourier.component.ts b/src/app/pages/algorithms/fourier/fourier.component.ts index d1fe8c4..a0c79b0 100644 --- a/src/app/pages/algorithms/fourier/fourier.component.ts +++ b/src/app/pages/algorithms/fourier/fourier.component.ts @@ -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 {MatButton} from '@angular/material/button'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; @@ -32,6 +33,7 @@ import { }) export class FourierComponent implements AfterViewInit, OnDestroy { private readonly fourierService = inject(FourierService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly themeService = inject(ThemeService); private readonly ngZone = inject(NgZone); @@ -75,6 +77,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy { protected readonly MAX_SPEED = MAX_SPEED; ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.ctx = this.canvasRef.nativeElement.getContext('2d')!; this.resizeCanvas(); this.applyPreset(Preset.HEART); @@ -84,6 +89,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { + if (!this.isBrowser) { + return; + } cancelAnimationFrame(this.animationFrameId); } diff --git a/src/app/pages/imprint/imprint.component.ts b/src/app/pages/imprint/imprint.component.ts index c1579c7..b6b9762 100644 --- a/src/app/pages/imprint/imprint.component.ts +++ b/src/app/pages/imprint/imprint.component.ts @@ -1,7 +1,6 @@ import { Component } from '@angular/core'; import {MatCard} from '@angular/material/card'; import {TranslatePipe} from '@ngx-translate/core'; -import {SharedFunctions} from '../../shared/SharedFunctions'; @Component({ selector: 'app-imprint', @@ -12,7 +11,4 @@ import {SharedFunctions} from '../../shared/SharedFunctions'; templateUrl: './imprint.component.html', styleUrl: './imprint.component.scss', }) -export class ImprintComponent { - - protected readonly SharedFunctions = SharedFunctions; -} +export class ImprintComponent {} diff --git a/src/app/pages/privacy/privacy.component.html b/src/app/pages/privacy/privacy.component.html new file mode 100644 index 0000000..498232a --- /dev/null +++ b/src/app/pages/privacy/privacy.component.html @@ -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@andreas-dahm.eu"> + info@andreas-dahm.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> diff --git a/src/app/pages/privacy/privacy.component.spec.ts b/src/app/pages/privacy/privacy.component.spec.ts new file mode 100644 index 0000000..d8dcdcf --- /dev/null +++ b/src/app/pages/privacy/privacy.component.spec.ts @@ -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); + } +}); diff --git a/src/app/pages/privacy/privacy.component.ts b/src/app/pages/privacy/privacy.component.ts new file mode 100644 index 0000000..cfccb7b --- /dev/null +++ b/src/app/pages/privacy/privacy.component.ts @@ -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}`); +} diff --git a/src/app/pages/projects/projects.component.ts b/src/app/pages/projects/projects.component.ts index 91675f9..7117175 100644 --- a/src/app/pages/projects/projects.component.ts +++ b/src/app/pages/projects/projects.component.ts @@ -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 { Subscription } from "rxjs"; import { MatCardModule } from "@angular/material/card"; @@ -49,6 +50,7 @@ export interface Projects { export class ProjectsComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly dialog = inject(MatDialog); private readonly router = inject(Router); private queryParamSub: Subscription | undefined; @@ -283,7 +285,9 @@ export class ProjectsComponent implements OnInit, OnDestroy { } ngOnInit(): void { - window.scrollTo({ top: 0, behavior: 'smooth' }); + if (this.isBrowser) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } setTimeout(() => { this.dialogOpenFunction(); }, 10); } diff --git a/src/app/shared/SharedFunctions.ts b/src/app/shared/SharedFunctions.ts index f91283e..a2dafe2 100644 --- a/src/app/shared/SharedFunctions.ts +++ b/src/app/shared/SharedFunctions.ts @@ -1,12 +1,5 @@  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 { return Math.floor(Math.random() * (max - min + 1) + min); } diff --git a/src/app/shared/components/generic-grid/generic-grid.ts b/src/app/shared/components/generic-grid/generic-grid.ts index 77c3c86..435da73 100644 --- a/src/app/shared/components/generic-grid/generic-grid.ts +++ b/src/app/shared/components/generic-grid/generic-grid.ts @@ -1,5 +1,5 @@ -import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core'; -import {CommonModule} from '@angular/common'; +import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, Output, PLATFORM_ID, ViewChild} from '@angular/core'; +import {CommonModule, isPlatformBrowser} from '@angular/common'; export interface GridPos { row: number; col: number } @@ -31,6 +31,7 @@ export class GenericGridComponent implements AfterViewInit { @Output() gridChange = new EventEmitter<any[][]>(); @Output() nodeClick = new EventEmitter<GridPos>(); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private ctx!: CanvasRenderingContext2D; grid: any[][] = []; @@ -38,6 +39,9 @@ export class GenericGridComponent implements AfterViewInit { protected lastCell: GridPos | null = null; ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.ctx = this.getContextOrThrow(); this.setupCanvasListeners(); this.applyGridSize(); @@ -156,6 +160,9 @@ export class GenericGridComponent implements AfterViewInit { } drawNode(node: any): void { + if (!this.ctx) { + return; + } this.ctx.fillStyle = this.getNodeColorFn(node); this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize); diff --git a/src/app/shared/components/particles-background/particles-background.component.ts b/src/app/shared/components/particles-background/particles-background.component.ts index 8a76159..4dc0ed0 100644 --- a/src/app/shared/components/particles-background/particles-background.component.ts +++ b/src/app/shared/components/particles-background/particles-background.component.ts @@ -40,6 +40,9 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { + if (!this.isBrowser) { + return; + } cancelAnimationFrame(this.animationFrameId); } diff --git a/src/app/shared/components/render-canvas/babylon-canvas.component.ts b/src/app/shared/components/render-canvas/babylon-canvas.component.ts index 362b330..ba93e0c 100644 --- a/src/app/shared/components/render-canvas/babylon-canvas.component.ts +++ b/src/app/shared/components/render-canvas/babylon-canvas.component.ts @@ -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 {TranslateService} from '@ngx-translate/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 translate = inject(TranslateService); private readonly gpuCapability = inject(GpuCapabilityService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); @ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>; @@ -54,10 +56,16 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy { private readonly wheelHandler = (evt: WheelEvent) => evt.preventDefault(); ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initBabylon().then(() => { console.log("Engine initialized"); }); } ngOnDestroy(): void { + if (!this.isBrowser) { + return; + } window.removeEventListener('resize', this.resizeHandler); const canvas = this.canvasRef?.nativeElement; diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 5da8d4c..f9375ef 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -33,7 +33,6 @@ "ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt", "LOCATION": "München · Remote", "VIEW_PROJECTS": "Projekte ansehen", - "CONTACT_ME": "Kontaktiere mich", "SECTION": { "SKILLS": "Fähigkeiten & Stack", "LANGUAGES": "Sprachen & Frameworks", @@ -416,6 +415,40 @@ "COUNTRY": "Deutschland", "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": { "TITLE": "Pfadfindungsalgorithmen", "START_NODE": "Startknoten", @@ -738,6 +771,10 @@ "TITLE": "Impressum – Andreas Dahm", "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": { "TITLE": "Stoppuhr – Andreas Dahm", "DESCRIPTION": "Eine einfache Online-Stoppuhr mit konfigurierbaren Intervall-Signaltönen." diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index de4cdc6..4730984 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -33,7 +33,6 @@ "ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect", "LOCATION": "Munich · Remote", "VIEW_PROJECTS": "View projects", - "CONTACT_ME": "Contact me", "SECTION": { "SKILLS": "Skills & Stack", "LANGUAGES": "Languages & Frameworks", @@ -416,6 +415,40 @@ "COUNTRY": "Germany", "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": { "TITLE": "Pathfinding Algorithms", "START_NODE": "Start Node", @@ -738,6 +771,10 @@ "TITLE": "Imprint – Andreas Dahm", "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": { "TITLE": "Stopwatch – Andreas Dahm", "DESCRIPTION": "A simple online stopwatch with configurable interval beeps." diff --git a/src/assets/icons/apple-touch-icon.png b/src/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dbc61911f0e12b9119f769d02adc7e3773e133b1 GIT binary patch literal 2733 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 N4ws#6YOQYG`yXP?9+?0D literal 0 HcmV?d00001 diff --git a/src/index.html b/src/index.html index 8cef26c..6590bef 100644 --- a/src/index.html +++ b/src/index.html @@ -12,7 +12,7 @@ <meta name="theme-color" content="#313131"> <link rel="canonical" href="https://andreas-dahm.eu/"> <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:site_name" content="Andreas Dahm – Playground"> @@ -34,11 +34,6 @@ fetch in parallel with the bundle instead of after it. --> <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> <body> <app-root class="flex flex-col min-h-screen"></app-root> diff --git a/src/main.server.ts b/src/main.server.ts new file mode 100644 index 0000000..77ef8f8 --- /dev/null +++ b/src/main.server.ts @@ -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; diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest index c7b5d63..296b960 100644 --- a/src/manifest.webmanifest +++ b/src/manifest.webmanifest @@ -14,11 +14,11 @@ "src": "assets/icons/app-icon.svg", "sizes": "any", "type": "image/svg+xml", - "purpose": "any maskable" + "purpose": "any" }, { "src": "favicon.ico", - "sizes": "16x16 29x32", + "sizes": "16x16 32x32", "type": "image/x-icon" } ] diff --git a/src/tailwind.css b/src/tailwind.css index 4ab6a32..c8beec1 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -44,16 +44,6 @@ @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 { @apply relative;