Compare commits
3
Commits
57f861510e
...
e13db8c1de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13db8c1de | ||
|
|
ee9caf71cb | ||
|
|
54b93055ba |
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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.
|
||||
- [ ] **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`.
|
||||
- [ ] **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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+22
-4
@@ -3,6 +3,14 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# No access log: no visitor IPs stored (privacy page says so)
|
||||
access_log off;
|
||||
|
||||
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 +49,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 +63,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 <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",
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+69
-16
@@ -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",
|
||||
|
||||
+6
-1
@@ -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"
|
||||
|
||||
@@ -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 {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',
|
||||
|
||||
@@ -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.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' } },
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
LINK: '/imprint',
|
||||
};
|
||||
|
||||
static readonly PRIVACY = {
|
||||
PATH: 'privacy',
|
||||
LINK: '/privacy',
|
||||
};
|
||||
|
||||
static readonly STOPWATCH = {
|
||||
PATH: 'stopwatch',
|
||||
LINK: '/stopwatch',
|
||||
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 { 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 {
|
||||
if (this.isBrowser) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
setTimeout(() => { this.dialogOpenFunction(); }, 10);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
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 {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;
|
||||
|
||||
+40
-3
@@ -30,10 +30,9 @@
|
||||
},
|
||||
"HELLO": "Hallo, ich bin Andreas.",
|
||||
"LEAD": "Ich arbeite seit 2010 als Softwareentwickler, die meiste Zeit davon in zwei recht unterschiedlichen Bereichen. Der eine ist Echtzeit-3D-Simulation: Stoffphysik, Kollisionserkennung, GPU-Shader. Der andere ist Cloud-Architektur für größere Unternehmensplattformen. Ein paar Jahre habe ich ein kleines Backend-Team geleitet, den Umbau eines großen Legacy-Monolithen Richtung Microservices geplant und zusammen mit meinem Bruder ein Spiel auf Steam veröffentlicht. Zuletzt stecke ich viel Zeit in Tooling rund um KI-gestützte Entwicklung, vor allem in die Frage, wie das Ergebnis überprüfbar bleibt. Privat baue ich Spiele und probiere Sachen mit Algorithmen aus. Das meiste davon landet irgendwann auf dieser Seite.",
|
||||
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
|
||||
"ROLE": "Senior-Softwareentwickler · Cloud-Architektur & 3D-Simulation",
|
||||
"LOCATION": "München · Remote",
|
||||
"VIEW_PROJECTS": "Projekte ansehen",
|
||||
"CONTACT_ME": "Kontaktiere mich",
|
||||
"SECTION": {
|
||||
"SKILLS": "Fähigkeiten & Stack",
|
||||
"LANGUAGES": "Sprachen & Frameworks",
|
||||
@@ -100,7 +99,7 @@
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Architektur und Implementierung der Datenbankanbindung mit Hibernate 6.x.",
|
||||
"P2": "Konzeption und Entwicklung einer Full-Stack-Webanwendung für Kollisionsanalysen (Angular + Spring Boot + Docker).",
|
||||
"P3": "Entwicklung eines Kostenanalysetools mit dem Namen MIDO."
|
||||
"P3": "Entwicklung von MIDO, einem Tool zur Kostenkalkulation von Spritzguss-, Druckguss- und Stanzwerkzeugen: 3D-Layout von Werkzeugnest und Kavitäten, Bestimmung der Entformungsrichtung, Hinterschnitt-Erkennung und Kostenaufschlüsselung nach Werkzeugelementen."
|
||||
}
|
||||
},
|
||||
"COMPANY7": {
|
||||
@@ -416,6 +415,40 @@
|
||||
"COUNTRY": "Deutschland",
|
||||
"CONTACT": "Kontakt"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Datenschutzerklärung",
|
||||
"CONTROLLER": "Verantwortlicher",
|
||||
"SECTIONS": {
|
||||
"HOSTING": {
|
||||
"TITLE": "Hosting",
|
||||
"TEXT": "Diese Seite läuft auf einem virtuellen Server (VPS) von Hostinger International Ltd., 61 Lordou Vironos str., 6023 Larnaca, Zypern. Mit Hostinger besteht ein Auftragsverarbeitungsvertrag nach Art. 28 DSGVO (Hostinger Data Processing Agreement). Der Webserver speichert keine Zugriffs-Logs. Nur bei technischen Fehlern kann ein Fehler-Log kurzzeitig die IP-Adresse enthalten. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (sicherer Betrieb der Seite)."
|
||||
},
|
||||
"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."
|
||||
|
||||
+40
-3
@@ -30,10 +30,9 @@
|
||||
},
|
||||
"HELLO": "Hello, I’m Andreas.",
|
||||
"LEAD": "I have been working as a software developer since 2010. Most of that time went into two fairly different corners. One is real-time 3D simulation: cloth physics, collision detection, GPU shaders. The other is cloud architecture for larger enterprise platforms. I led a small backend team for a few years, planned the move of a big legacy monolith towards microservices, and released a game on Steam together with my brother. Lately a lot of my time goes into tooling around AI-assisted development, mostly into making the output reviewable instead of just fast. In my free time I build games and mess around with algorithms. Most of that ends up somewhere on this site.",
|
||||
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
|
||||
"ROLE": "Senior Software Engineer · Cloud Architecture & 3D Simulation",
|
||||
"LOCATION": "Munich · Remote",
|
||||
"VIEW_PROJECTS": "View projects",
|
||||
"CONTACT_ME": "Contact me",
|
||||
"SECTION": {
|
||||
"SKILLS": "Skills & Stack",
|
||||
"LANGUAGES": "Languages & Frameworks",
|
||||
@@ -100,7 +99,7 @@
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Architecture and implementation of database connectivity using Hibernate 6.x.",
|
||||
"P2": "Design and development of a full-stack web application for collision analysis (Angular + Spring Boot + Docker).",
|
||||
"P3": "Development of a cost analysis tool called MIDO."
|
||||
"P3": "Development of MIDO, a cost calculation tool for injection molding, die casting and stamping tools: 3D layout of the mold and its cavities, demolding direction, undercut detection and a cost breakdown by tool component."
|
||||
}
|
||||
},
|
||||
"COMPANY7": {
|
||||
@@ -416,6 +415,40 @@
|
||||
"COUNTRY": "Germany",
|
||||
"CONTACT": "Contact"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Privacy policy",
|
||||
"CONTROLLER": "Controller",
|
||||
"SECTIONS": {
|
||||
"HOSTING": {
|
||||
"TITLE": "Hosting",
|
||||
"TEXT": "This site runs on a virtual private server (VPS) from Hostinger International Ltd., 61 Lordou Vironos str., 6023 Larnaca, Cyprus. A data processing agreement under Art. 28 GDPR is in place with Hostinger (Hostinger Data Processing Agreement). The web server stores no access logs. Only in case of technical errors an error log may briefly contain the IP address. Legal basis: Art. 6 (1) (f) GDPR (secure operation of the site)."
|
||||
},
|
||||
"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."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+1
-6
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "16x16 29x32",
|
||||
"sizes": "16x16 32x32",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user