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:
1 parent
57f861510e
commit
54b93055ba
42 files changed
+544
-88
No files matched your search
@@ -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: |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+69
-16
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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))],
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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@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>
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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@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>
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user