Compare commits

...
Author SHA1 Message Date
fraujulianandClaude Opus 5.5 f3cc9c055c docs(readme): document lang parameter and drop OpenPGP feature
CI / metadata (push) Successful in 54s
CI / formatting (push) Successful in 58s
CI / lint (push) Successful in 1m4s
CI / tests (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:24:55 +02:00
fraujulianandClaude Opus 5.5 801d71ee26 chore(assets): remove outdated culture portrait
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:24:43 +02:00
fraujulianandClaude Opus 5.5 1a47b37a70 chore: remove OpenPGP key download
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:24:31 +02:00
fraujulianandClaude Opus 5.5 84589156f6 style: unify colors across devices and refine hero, projects and dialog layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:24:18 +02:00
fraujulianandClaude Opus 5.5 8749ade6e1 feat(imprint): shorten legal sections and add privacy notice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:24:05 +02:00
fraujulianandClaude Opus 5.5 1c8f76c81c feat(home): rework hero, skills ticker, project cards and image loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:23:53 +02:00
fraujulianandClaude Opus 5.5 20aaf4faa7 feat(a11y): use native dialog for language selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:23:41 +02:00
fraujulianandClaude Opus 5.5 9ede5c02c1 feat(content): update translations, skills list and imprint texts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:23:19 +02:00
fraujulianandClaude Opus 5.5 c8af80cee3 feat(contact): add Signal and site URLs, update email and drop state from address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:23:08 +02:00
fraujulianandClaude Opus 5.5 88ae2f4f14 feat(icons): replace GitHub and Discord icons with Gitea and Signal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:56 +02:00
fraujulianandClaude Opus 5.5 d5cbf3220a feat(seo): sync html lang, title, canonical, hreflang and Open Graph tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:43 +02:00
fraujulianandClaude Opus 5.5 28b1b88ace feat(i18n): pick language from ?lang, saved choice or browser language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:30 +02:00
fraujulianandClaude Opus 5.5 614b331188 build(deps): remove unused packages from package.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:22:10 +02:00
fraujulianandClaude Opus 5.5 662ef5bcf9 feat(index): show bilingual noscript hint before app boot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:44 +02:00
fraujulianandClaude Opus 5.5 f8640e7e54 feat(seo): add social meta tags, structured data and image preloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:34 +02:00
fraujulianandClaude Opus 5.5 6e76b8d199 feat(footer): move noscript hint to index.html and link home from imprint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:42 +02:00
fraujulianandClaude Opus 5.5 afc9b875d2 feat(seo): add imprint and German URLs to sitemap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:30 +02:00
fraujulianandClaude Opus 5.5 584a5f95d3 feat(assets): self-host Outfit font and generate favicons and social image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:25 +02:00
fraujulianandClaude Opus 5.5 f1abef50a9 feat(server): add security headers, compression and 404 for unknown routes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:18 +02:00
fraujulianandClaude Opus 5.5 1d8d88bc3d docs(agents): update Angular version to 22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:13 +02:00
fraujulianandClaude Opus 5.5 a84d0dae21 build(deps): remove unused Angular, server and markdown packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:07 +02:00
fraujulianandClaude Opus 5.5 def1897723 docs: describe Vitest test setup
CI / formatting (push) Successful in 58s
CI / lint (push) Successful in 1m18s
CI / metadata (push) Successful in 1m11s
CI / tests (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:45:05 +02:00
fraujulianandClaude Opus 5.5 2764ea0c96 ci: run tests without Chrome
Vitest runs the specs in jsdom, so the Chrome setup step is no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:53 +02:00
fraujulianandClaude Opus 5.5 ca1cf8aefe test: migrate from Karma and Jasmine to Vitest
Karma is deprecated and depends on braces, which has no patched release for GHSA-vfj7-8cjw-p6xm. Tests now run through the @angular/build unit-test builder with Vitest and jsdom. fakeAsync is replaced by Vitest fake timers, and zone.js is dropped because the zoneless app only loaded it for the Karma test polyfills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:40 +02:00
fraujulianandClaude Opus 5.5 81613d701a build: use @angular/build builders
CI / formatting (push) Successful in 46s
CI / metadata (push) Successful in 45s
CI / lint (push) Successful in 55s
CI / tests (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:27:30 +02:00
fraujulianandClaude Opus 5.5 2bebcd8527 build(deps-dev): replace deprecated build-angular with @angular/build
Angular's Webpack-based @angular-devkit/build-angular is deprecated. Depending on @angular/build directly drops webpack-dev-server and http-proxy-middleware, which pulled in the unpatched braces advisory GHSA-vfj7-8cjw-p6xm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:26:53 +02:00
fraujulianandClaude Opus 5.5 6e0dd310ec build(deps): update markdown-it to 15.0.2
markdown-it 15 ships its own type declarations, so @types/markdown-it is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:24:53 +02:00
fraujulianandClaude Opus 5.5 dd07852c68 build(deps-dev): remove unused del-cli
del-cli is not referenced anywhere and pulls in the unpatched braces advisory GHSA-vfj7-8cjw-p6xm via fast-glob.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:24:21 +02:00
fraujulianandClaude Opus 5.5 8ad3c8e01b build(deps-dev): update postcss to 8.5.29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:24:03 +02:00
fraujulianandClaude Opus 5.5 5b23378a5a build(deps-dev): update eslint-plugin-n to 18.4.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:23:45 +02:00
fraujulianandClaude Opus 5.5 9c3921dabb build(deps-dev): update typescript-eslint to 8.71.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:23:08 +02:00
fraujulian d07db3d3cd fix: use checked-out commit in Gitea CD
CI / metadata (push) Successful in 53s
CI / formatting (push) Successful in 53s
CI / lint (push) Successful in 1m6s
CI / tests (push) Successful in 1m59s
2026-10-02 15:19:05 +02:00
fraujulian 0ae27adf90 ci: share Gitea CD publish action
CI / metadata (push) Successful in 1m25s
CI / formatting (push) Successful in 1m31s
CI / lint (push) Successful in 1m36s
CI / tests (push) Successful in 2m22s
2026-10-02 15:08:22 +02:00
fraujulian 4a43d55b64 ci: use dev image tag
CI / formatting (push) Successful in 46s
CI / metadata (push) Successful in 47s
CI / lint (push) Successful in 53s
CI / tests (push) Successful in 3m29s
2026-10-02 14:53:56 +02:00
fraujulian 46ec796b79 ci: align Gitea release workflows
CI / metadata (push) Successful in 1m2s
CI / formatting (push) Successful in 1m2s
CI / lint (push) Successful in 1m19s
CI / tests (push) Successful in 3m2s
2026-10-02 14:43:39 +02:00
fraujulian ba0663ee73 fix: align Jasmine and Docker Node versions
CI / metadata (push) Successful in 49s
CI / formatting (push) Successful in 54s
CI / lint (push) Successful in 1m0s
CI / tests (push) Successful in 1m50s
2026-10-02 13:49:52 +02:00
fraujulian 73747ad8d2 docs: reorganize portfolio README in English
CI / formatting (push) Successful in 1m9s
CI / lint (push) Successful in 1m12s
CI / metadata (push) Successful in 1m5s
CI / tests (push) Failing after 1m51s
2026-10-02 13:37:38 +02:00
fraujulian eeb80fc00c ci: rename release workflows to CD
CI / lint (push) Successful in 1m34s
CI / formatting (push) Successful in 1m11s
CI / metadata (push) Successful in 1m4s
CI / tests (push) Failing after 1m59s
2026-10-02 13:21:11 +02:00
fraujulian 95375c4c75 ci: run headless Chrome without sandbox in root runner
CI / metadata (push) Successful in 1m13s
CI / formatting (push) Successful in 1m18s
CI / lint (push) Successful in 1m25s
CI / tests (push) Failing after 3m59s
2026-10-02 13:19:42 +02:00
fraujulian df1438e136 deps: upgrade compatible packages and resolve advisories
CI / metadata (push) Successful in 1m2s
CI / lint (push) Successful in 1m8s
CI / formatting (push) Successful in 1m7s
CI / tests (push) Failing after 2m3s
2026-10-02 13:11:30 +02:00
fraujulian 63d68f6e14 ci: gate scheduled development releases on changes
CI / tests (push) Failing after 5m57s
CI / lint (push) Canceled after 7m41s
CI / formatting (push) Canceled after 7m27s
CI / metadata (push) Canceled after 7m25s
2026-10-02 11:52:49 +02:00
fraujulian aa2a2d409b ci: rename production release workflow 2026-10-02 11:52:13 +02:00
fraujulian 5903c18657 ci: split Gitea validation into parallel jobs 2026-10-02 11:52:03 +02:00
fraujulian a8b313dd16 fix(links): point portfolio links to Gitea
Build Docker Image - Dev / docker (push) Failing after 33s
Build Validation / build (push) Failing after 1m35s
2026-07-22 22:41:40 +02:00
fraujulian e8bc2cd91e docs: update repository links for Gitea 2026-07-22 22:41:26 +02:00
fraujulian 77cc73d659 chore(repo): update Gitea metadata 2026-07-22 22:41:12 +02:00
fraujulian afc445aa19 ci(gitea): migrate release workflow 2026-07-22 22:40:59 +02:00
fraujulian 92a1cd803f ci(gitea): migrate development image workflow 2026-07-22 22:40:46 +02:00
fraujulian 9c61526f9b ci(gitea): migrate validation workflow 2026-07-22 22:40:27 +02:00
fraujulian 64dc8dc7cb chore(gitea): remove unsupported GitHub metadata 2026-07-22 22:40:04 +02:00
fraujulian 942744556b chore(gitea): migrate issue templates 2026-07-22 22:39:50 +02:00
fraujulian cad41e5dc0 chore: updated LICENSE 2026-05-15 15:42:50 +02:00
fraujulian c99d086745 chore: updated README.md 2026-05-15 15:36:49 +02:00
fraujulian f0463229a1 adding opgenpgp key 2026-05-15 15:35:31 +02:00
fraujulian 17cb23aa40 refactorings 2026-05-15 14:06:38 +02:00
fraujulian 24862a33d0 fix: performance and style 2026-05-14 15:57:12 +02:00
fraujulian d8d1528fa9 improve: performance 2026-05-14 15:36:37 +02:00
fraujulian 9223fc3a12 improve: performance 2026-05-14 15:27:36 +02:00
fraujulian 6f9cf076cb fix: ALLOWED_HOSTS 2026-05-14 15:06:31 +02:00
fraujulian 9cc7249278 fix: fragmentation 2026-05-14 14:57:54 +02:00
fraujulian 13693165df fix: error 2026-05-14 14:40:55 +02:00
fraujulian 5a22e7f614 improve: removed dns from docker compose 2026-05-14 14:34:29 +02:00
fraujulian 9069f4f6fc feat: always scroll to top on page switch 2026-05-14 14:31:00 +02:00
fraujulian 3bc31ec45f improve: text and design for the imprint 2026-05-14 14:14:20 +02:00
fraujulian afcee10431 improve: german and english languages 2026-05-14 14:07:25 +02:00
fraujulian 30c2ac0273 improve: german and english languages 2026-05-14 14:01:50 +02:00
fraujulian 806c04e47a improve: performance 2026-05-14 13:34:56 +02:00
fraujulian b598779c60 improve: best practises 2026-05-14 13:30:43 +02:00
fraujulian 1ce29e47a5 chore: updated dependencies 2026-05-14 13:24:21 +02:00
fraujulian 2692c3c544 improve: check script 2026-05-14 13:04:16 +02:00
fraujulian 1a41621d0f improve: port management 2026-05-14 13:01:37 +02:00
fraujulian 3d81db7d53 fix: style; feat: AGENTS.md 2026-05-14 12:52:11 +02:00
fraujulian 3efece9e29 fix: style 2026-04-29 22:37:38 +02:00
fraujulian 3f51d3c505 fix: style 2026-04-29 22:24:15 +02:00
fraujulian 1e2b1efc66 fix: performance 2026-04-28 21:44:20 +02:00
fraujulian 07000e539c fix: performance 2026-04-28 19:05:22 +02:00
fraujulian c6eb4fd452 fix: language errors 2026-04-28 18:47:42 +02:00
fraujulian c7ccbfe6c7 lint 2026-04-28 13:53:33 +02:00
fraujulian 735b5fc206 feat: added languages 2026-04-28 13:52:38 +02:00
fraujulian d5d995c50d fix: text 2026-04-28 12:31:08 +02:00
fraujulian 3b30030500 Merge remote-tracking branch 'origin/main' 2026-04-27 19:08:25 +02:00
fraujulian 22f50266fe fix: ignore files 2026-04-27 19:08:11 +02:00
fraujulian 61b5de597f Delete .claude/settings.local.json 2026-04-27 19:07:17 +02:00
fraujulian e6ad86e924 Update README formatting and content 2026-04-27 19:05:05 +02:00
fraujulian 254cf50b1a feat: texts 2026-04-27 18:59:55 +02:00
fraujulian 3fadc5e0e7 fix: lint 2026-04-27 18:42:46 +02:00
fraujulian 2938b920c5 improve: no script stuff 2026-04-27 18:41:16 +02:00
fraujulian 79b585f6c5 fix: ci tests 2026-04-27 18:28:40 +02:00
fraujulian 68ff51f8f4 improve: added tests to ci 2026-04-27 18:15:52 +02:00
fraujulian 27c196d652 fix: lint 2026-04-27 18:14:05 +02:00
fraujulian f7e9a0650b improve: tests 2026-04-27 18:11:36 +02:00
fraujulian 0d7b8a59a5 improve: readme and configs 2026-04-27 17:42:27 +02:00
fraujulian 38d4f7b2a6 improve: my text 2026-04-27 17:33:17 +02:00
fraujulian 4ada33b011 feat: imprint 2026-04-27 17:06:55 +02:00
fraujulian 026af41c4d feat: imprint 2026-04-27 17:06:08 +02:00
fraujulian 87fe28cbfc feat: added images 2026-04-27 16:46:26 +02:00
fraujulian 0160c2aec6 fix: performance 2026-04-27 16:12:42 +02:00
fraujulian 65e5249512 fix: performance 2026-04-27 15:53:19 +02:00
fraujulian ef10c2efe7 fix: lint 2026-04-27 14:51:15 +02:00
fraujulian f516438de3 fix: performance 2026-04-27 14:50:17 +02:00
fraujulian 108a686d7c fix: style 2026-04-27 14:34:04 +02:00
fraujulian 500329d88a fix: style 2026-04-24 19:04:55 +02:00
fraujulian 59ae95b874 feat: animations 2026-04-24 18:54:34 +02:00
fraujulian c52856aaba Revert "fix: performance"
This reverts commit cc39abed41.
2026-04-24 18:19:54 +02:00
fraujulian cc39abed41 fix: performance 2026-04-24 15:57:18 +02:00
fraujulian f0bf2e20ce feat: style 2026-04-24 15:32:30 +02:00
fraujulian c899b53cca feat: style 2026-04-24 14:23:58 +02:00
fraujulian e87c26d4c9 fix: url 2026-04-23 18:52:29 +02:00
fraujulian 5fb4511b28 fix 2026-04-23 18:42:12 +02:00
fraujulian 87546628d4 feat: style and infos 2026-04-23 18:38:57 +02:00
fraujulian 857a35e8f8 feat: added pixel calucation 2026-04-23 18:00:35 +02:00
fraujulian 688dadf96d feat: added title 2026-04-23 17:55:45 +02:00
fraujulian 869c9b999f feat: removed # refs 2026-04-23 17:50:23 +02:00
fraujulian 8838d360d3 refactor: calculateAge Function 2026-04-23 17:46:03 +02:00
fraujulian f7f1cc2070 refactor: Dockerfile 2026-04-21 10:53:17 +02:00
fraujulian a33ba3e304 feat: style 2026-04-20 23:07:02 +02:00
fraujulian a2a77d01a6 feat: style 2026-04-20 23:03:33 +02:00
fraujulian 7cb841072d feat: style 2026-04-20 23:01:22 +02:00
fraujulian 38f391d525 feat: style 2026-04-20 22:59:35 +02:00
fraujulian 7a967e4d55 feat: style 2026-04-20 22:00:06 +02:00
fraujulian 0888795c09 feat: style 2026-04-20 21:57:57 +02:00
fraujulian 7bbcdfa1b9 fix: performance 2026-04-18 00:14:34 +02:00
fraujulian 4cb3998a00 fix: performance 2026-04-18 00:12:54 +02:00
fraujulian eef9c65840 fucked up 2026-04-17 23:51:29 +02:00
fraujulian 7a57197158 fucked up 2026-04-17 23:42:59 +02:00
fraujulian d34b2aa669 feat: reworked portfolio 2026-04-17 23:40:29 +02:00
103 changed files with 15022 additions and 12677 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# Git- und Projekt-Metadaten werden nicht in den Docker-Build-Kontext kopiert
.git
.gitea
.husky
.claude
# Lokale Editor-/IDE-Dateien
.idea
.vscode
# Build- und Tool-Caches
.angular
node_modules
dist
public
.npm-cache
coverage
.eslintcache
src/assets/optimized
# Lokale Log- und Debug-Dateien
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.log
*.tsbuildinfo
# Lokale Secrets und Umgebungsdateien
.env
.env.*
backend/.env
backend/.env.*
+2 -10
View File
@@ -1,17 +1,9 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+2
View File
@@ -0,0 +1,2 @@
IMAGE_TAG=latest
MAIN_PORT=3000
+43
View File
@@ -0,0 +1,43 @@
name: 🐛 Bug Report
about: Report a bug or unexpected behavior
title: '🐛 [BUG]: '
ref: main
body:
- type: textarea
id: information
attributes:
label: Information
description: Provide all information about the issue.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Step-by-step instructions to reproduce the issue.
placeholder: |
1. Go to ...
2. Click on ...
3. Observe ...
validations:
required: false
- type: textarea
id: logos
attributes:
label: Logs or Error Messages
description: Paste any relevant console output, stack traces, or screenshots.
placeholder: 'Console output, stacktrace, or screenshots here'
validations:
required: false
- type: input
id: occurrence
attributes:
label: Occurrence Version
description: Specify in which version the issue occurred
placeholder: 'v0.1.TTMMJJ-hhmm'
validations:
required: true
+2
View File
@@ -0,0 +1,2 @@
# Require one of the structured issue templates below.
blank_issues_enabled: false
+30
View File
@@ -0,0 +1,30 @@
name: 💡 Enhancement
about: Suggest a new feature or improvement
title: '💡 [ENHANCEMENT]: '
ref: main
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the new feature or improvement!
placeholder: 'What problem does it solve or what value does it add?'
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution
placeholder: 'Describe how the feature should work.'
validations:
required: false
- type: input
id: implementation
attributes:
label: Implementation Version
description: Specify in which version the enhancement should be released.
placeholder: 'v0.1'
validations:
required: true
+40
View File
@@ -0,0 +1,40 @@
name: ⚙️ Improvement
about: Suggest an improvement to existing functionality, code quality, or performance
title: '⚙️ [Improvement]: '
ref: main
body:
- type: textarea
id: context
attributes:
label: Current Situation / Context
description: Describe the current behavior or implementation that could be improved.
placeholder: 'Currently, the system loads all users at once, which slows down response time.'
validations:
required: false
- type: textarea
id: proposal
attributes:
label: Proposed Improvement
description: Describe your proposed change or improvement in detail.
placeholder: 'Implement pagination for user loading to reduce memory usage and response time.'
validations:
required: false
- type: textarea
id: benefits
attributes:
label: Expected Benefits
description: Explain how this improvement will help (e.g., better performance, cleaner code, easier maintenance).
placeholder: 'Faster load times, improved scalability, and reduced database load.'
validations:
required: false
- type: input
id: implementation
attributes:
label: Implementation Version
description: Specify in which version the improvement should be released.
placeholder: 'v0.1'
validations:
required: true
+133
View File
@@ -0,0 +1,133 @@
name: Publish portfolio
description: Build the image and create the matching Gitea release
inputs:
image-tag:
description: Docker image tag
required: true
prerelease:
description: Whether the Gitea release is a pre-release
required: true
registry-username:
description: Registry username
required: true
registry-password:
description: Registry password or token
required: true
gitea-token:
description: Token for Gitea releases
required: true
runs:
using: composite
steps:
- name: Use Node.js v24.15.0
uses: actions/setup-node@v4
with:
node-version: '24.15.0'
- name: Set build version
shell: bash
run: npm run version:set
- name: Read version from package.json
id: version
shell: bash
env:
PRERELEASE: ${{ inputs.prerelease }}
run: |
version=$(node -p 'require("./package.json").version')
release_tag="v${version//+/-}"
if [ "$PRERELEASE" = true ]; then
release_tag="${release_tag}-dev"
fi
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: docker.lechner-systems.at
username: ${{ inputs.registry-username }}
password: ${{ inputs.registry-password }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
VCS_REF=${{ steps.version.outputs.revision }}
tags: docker.lechner-systems.at/lechnersystems/portfolio:${{ inputs.image-tag }}
- name: Create Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ inputs.gitea-token }}
RELEASE_TAG: ${{ steps.version.outputs.release_tag }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
commit_sha="$(git rev-parse HEAD)"
api_url="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases"
# ponytail: 50 recent releases cover normal reruns; paginate if old releases are rebuilt.
releases="$(curl --fail-with-body --silent --show-error \
--header "Authorization: token ${GITEA_TOKEN}" \
"${api_url}?limit=50")"
previous_tag="$(printf '%s' "$releases" | jq -r \
--arg tag "$RELEASE_TAG" \
--argjson prerelease "$PRERELEASE" \
'[.[] | select(.draft == false and .prerelease == $prerelease and .tag_name != $tag)][0].tag_name // empty')"
if [ -n "$previous_tag" ]; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${commit_sha}")"
full_changelog="${{ gitea.server_url }}/${{ gitea.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "$commit_sha")"
full_changelog=""
fi
if [ -z "$commit_messages" ]; then
commit_messages='- No commits since the previous release'
fi
{
echo '## Changes'
echo
printf '%s\n' "$commit_messages"
echo
if [ -n "$full_changelog" ]; then
echo "**Full Changelog**: ${full_changelog}"
fi
} > release-notes.md
jq --null-input \
--arg tag "$RELEASE_TAG" \
--arg target "$commit_sha" \
--argjson prerelease "$PRERELEASE" \
--rawfile body release-notes.md \
'{tag_name: $tag, target_commitish: $target, name: $tag, body: $body, prerelease: $prerelease}' \
> release.json
release_id="$(printf '%s' "$releases" | jq -r \
--arg tag "$RELEASE_TAG" \
'.[] | select(.tag_name == $tag) | .id')"
if [ -n "$release_id" ]; then
method=PATCH
release_url="${api_url}/${release_id}"
else
method=POST
release_url="$api_url"
fi
curl --fail-with-body --silent --show-error \
--request "$method" \
--header "Authorization: token ${GITEA_TOKEN}" \
--header 'Content-Type: application/json' \
--data-binary @release.json \
"$release_url"
+65
View File
@@ -0,0 +1,65 @@
name: CD Development
on:
workflow_dispatch:
schedule:
- cron: '0 23 * * *'
jobs:
changes:
runs-on: ubuntu-latest
permissions:
code: read
outputs:
changed: ${{ steps.changes.outputs.changed }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check changes since previous development release
id: changes
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/actions/workflows/CD%20Development.yml/runs
run: |
runs="$(curl --fail-with-body --silent --show-error \
--header "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}?branch=main&limit=50")"
previous_sha="$(printf '%s' "$runs" | jq -r \
--argjson current_run "$GITHUB_RUN_ID" \
'[.workflow_runs[] | select(.id != $current_run and .status == "success" and (.event == "schedule" or .event == "workflow_dispatch"))][0].head_sha // empty')"
if [ -z "$previous_sha" ]; then
echo 'changed=true' >> "$GITHUB_OUTPUT"
else
git cat-file -e "${previous_sha}^{commit}"
if git diff --quiet "$previous_sha" HEAD; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
echo 'No changes since the previous successful development release.'
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
fi
publish:
needs: changes
if: needs.changes.outputs.changed == 'true'
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish development image and pre-release
uses: ./.gitea/actions/publish
with:
image-tag: dev
prerelease: 'true'
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
gitea-token: ${{ secrets.GITEA_TOKEN }}
+25
View File
@@ -0,0 +1,25 @@
name: CD Production
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish latest image and stable release
uses: ./.gitea/actions/publish
with:
image-tag: latest
prerelease: 'false'
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
gitea-token: ${{ secrets.GITEA_TOKEN }}
+63
View File
@@ -0,0 +1,63 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
lint:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run lint
formatting:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run format:check
metadata:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run version:check
- run: npm run text:check
tests:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run test
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: fraujulian
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-26
View File
@@ -1,26 +0,0 @@
name: Build Validation
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.16.0
uses: actions/setup-node@v4
with:
node-version: '22.16.0'
- name: Install Dependencies
run: npm install
- name: run Build DEV
run: npm run buildDevelopment
- name: run Build PROD
run: npm run buildProduction
-40
View File
@@ -1,40 +0,0 @@
name: Build Solution
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v24.4.0
uses: actions/setup-node@v4
with:
node-version: '24.4.0'
- name: Install Dependencies
run: npm install
- name: run Build
run: npm run buildProduction
- name: get Version from package.json
id: get_version
run: |
version=$(node -p "require('./package.json').version")
echo "VERSION=$version" >> $GITHUB_ENV
- name: rename dist-Folder
run: mv dist "${{ env.VERSION }}"
- name: compress Version-Folder
run: zip -r "${{ env.VERSION }}-PROD.zip" "${{ env.VERSION }}"
- name: Upload Build-Artefact
uses: actions/upload-artifact@v4
with:
name: ${{ env.VERSION }}
path: ${{ env.VERSION }}
+34 -35
View File
@@ -1,42 +1,41 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
# Paketmanager-Abhaengigkeiten
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Lokale Tool-Caches
.npm-cache
.eslintcache
.claude
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
# Generierte Build- und Test-Ausgaben
/dist
/coverage
/libpeerconnection.log
testem.log
/typings
/public
/src/assets/optimized
# System files
# Betriebssystem-Dateileichen
.DS_Store
Thumbs.db
# Editor- und IDE-Konfigurationen
/.idea
/.vscode
# Lokale Log- und Debug-Dateien
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.log
*.tsbuildinfo
# Lokale Secrets und Umgebungsdateien
.env
.env.*
!.env.example
backend/.env
backend/.env.*
!backend/.env.example
# Angular-CLI-Cache
/.angular
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
npm run fix || exit 1
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
npm run check || exit 1
+39
View File
@@ -0,0 +1,39 @@
# Dependencies
/node_modules
# Build, coverage, and generated output
/dist
/coverage
/.angular
/public
src/assets/Resources/CSS/Tailwind.css
src/assets/optimized
# Package manager and tool caches
.npm-cache
.eslintcache
.prettier-cache
*.tsbuildinfo
# Logs and debug output
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Local environment and secrets
.env
.env.*
backend/.env
backend/.env.*
# Editor, IDE, and OS files
/.idea
/.vscode
.DS_Store
Thumbs.db
# Markdown is maintained manually and excluded from Prettier formatting
*.md
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"endOfLine": "auto",
"bracketSpacing": true,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "css",
"overrides": [
{
"files": "*.html",
"options": {
"printWidth": 140
}
},
{
"files": "*.md",
"options": {
"printWidth": 80,
"proseWrap": "always"
}
},
{
"files": "*.json",
"options": {
"printWidth": 120
}
},
{
"files": "*.scss",
"options": {
"printWidth": 120
}
}
]
}
+223
View File
@@ -0,0 +1,223 @@
# Repository Guidelines
## Project Context
This is an Angular portfolio application using Angular 22, TypeScript, SCSS,
Vitest tests, and static hosting for production output. Production
application code lives in `src/app`; the client entry point lives in
`src/main.ts`.
Feature views live in folders such as `src/app/home`, `src/app/footer`, and
`src/app/imprint`. Reusable generic UI building blocks belong in
`src/app/shared`; reusable services belong in `src/app/services`. Assets and
language content live in `src/assets` and `src/languages`. Do not manually
maintain generated or copied output in `public/assets/optimized` or `dist`.
## Agent Workflow
- Read relevant project files before making changes and follow existing
patterns.
- Always validate every change with `npm run check`.
- Do not run a build unless the user explicitly asks for it.
- If `npm run check` fails, read the cause first. `npm run fix` may be used, but
it cannot fix every issue.
- Always follow the active ESLint, Prettier, and `tsconfig` rules.
- Change only files that are relevant to the task.
- Do not revert unrelated or unclear local changes.
- Commit, push, or open PRs only when explicitly asked.
## Important Commands
- `npm run dev`: generates assets and starts the Angular development server.
- `npm run build`: generates assets, builds the production app, and copies
static root files. Run this only when explicitly requested.
- `npm start`: serves the built static app through `scripts/static-server.mjs`.
- `npm test`: generates assets and starts Angular tests with Vitest.
- `npm run check`: runs linting, format checks, version checks, and source text
checks. Always use this as the final validation.
- `npm run fix`: places version data, runs ESLint fixes, fixes source text, and
formats the project.
Use Node `>=24.14.0` and npm `>=11.12.0`.
## Code Style
- TypeScript strict mode is enabled. `strictTemplates` is enabled.
- Prettier is authoritative: 2 spaces, single quotes, semicolons, trailing
commas, and default print width 100.
- Markdown wraps at 80 characters.
- Angular selectors use the `app` prefix.
- Components follow the pattern `feature.component.ts`,
`feature.component.html`, and `feature.component.spec.ts`.
- Services follow the pattern `name.service.ts`.
- Place shared types in `*.types.ts` files when that matches the local pattern.
- Explicitly type public methods, service APIs, and complex return values.
- Use `unknown` instead of `any` when a value must be checked first.
- Use `any` only when technically necessary, and document the reason.
- Use `const` instead of `let` when reassignment is not needed.
- Use `readonly` for data that should not be changed.
- Do not use non-null assertions to hide real nullability issues.
- Do not suppress type errors with `as unknown as`.
- Use union types for fixed value sets instead of magic strings.
- Keep API DTOs separate from internal UI or domain models.
## Angular Architecture
- Components must have exactly one clear responsibility.
- Keep components primarily responsible for UI, user interaction, and view state.
- Put business logic, HTTP access, mapping, and shared state into focused
services.
- Structure code by features or domains, not only by file types.
- Put domain-specific logic and domain-specific components in the relevant
feature folder.
- Put generic reusable elements in `shared/`.
- Split container components from presentational components when a view becomes
complex.
- Create abstractions only when at least two real use cases exist.
- Avoid cyclic dependencies between components, services, and modules.
- Do not use services as unordered global storage.
## Angular Components
- Use `ChangeDetectionStrategy.OnPush` unless there is a concrete reason not to.
- Use required inputs when a component cannot work meaningfully without the
data.
- Name inputs and outputs clearly after their purpose.
- Do not mutate input data directly.
- Keep component classes small and readable.
- Extract repeated UI blocks into their own components.
- Use lifecycle hooks only when they are truly necessary.
- Keep constructors free of logic; prefer dependency injection with `inject()`
when it fits the existing code.
- Do not manipulate the DOM directly when Angular APIs are sufficient.
- Do not use component inheritance when composition is simpler.
## State, RxJS, and Data Flows
- Use Signals for local synchronous component state.
- Use computed Signals for derived synchronous values.
- Use RxJS for asynchronous data streams and complex event chains.
- Encapsulate shared state in services.
- Keep state as close as possible to where it is needed.
- Make state changes explicit and easy to follow.
- Name Observables with a `$` suffix.
- Use the `async` pipe when a stream is displayed directly in the template.
- Choose `switchMap`, `concatMap`, `mergeMap`, and `exhaustMap` deliberately
based on their semantics.
- Use `combineLatest` for dependent live values and `forkJoin` for multiple
one-time requests.
- End manual subscriptions with `takeUntilDestroyed` or an equivalent mechanism.
- Do not write `subscribe()` inside `subscribe()`.
- Handle errors with `catchError` in the right place and do not blindly swallow
them with `EMPTY`.
- Do not use RxJS for trivial synchronous state.
## HTTP and API
- Encapsulate all HTTP calls in services.
- Define response types for API responses.
- Keep API configuration centralized through environments or injection tokens.
- Use interceptors for recurring technical tasks such as auth, headers, or error
handling.
- Include loading, error, and empty states for slow or failed requests.
- Treat external data defensively, and validate or normalize it before broad UI
use.
- Map external API data deliberately into internal models.
- Avoid duplicate API calls through shared stream or cache handling.
- Do not hardcode API URLs, tokens, roles, or secrets in components.
## Templates
- Keep templates declarative and easy to read.
- Use templates only for presentation and simple bindings.
- Move complex conditions into component code or computed Signals.
- Use `@if` for conditional rendering.
- Always use `@for` with `track`.
- Use stable unique IDs for `track`; use the index only when no stable ID
exists.
- Do not put business rules, complex ternaries, or expensive method calls in
templates.
- Use pipes only for pure formatting.
- Break large templates into smaller components.
- Keep binding expressions short.
## Forms
- Use Reactive Forms for complex forms.
- Define validators explicitly.
- Show validation errors clearly in the UI.
- Validate critical rules again on the backend.
- Keep form models and API DTOs separate when they differ.
- Map form data deliberately before sending it.
- Disable submit during active requests when repeated submission is problematic.
- Show server errors visibly in the form.
## Styling
- Prefer component-scoped styling.
- Use global styles in `src/styles.scss` only for truly global rules.
- Use consistent design tokens, utility classes, or reusable components for
recurring UI patterns.
- Consider responsive design for every new view.
- Use semantic HTML before adding complex CSS.
- Keep spacing, colors, and font sizes consistent.
- Use inline styles only for dynamic edge cases.
- Do not use `::ng-deep` as a default solution.
- Do not write global CSS rules that unintentionally affect unrelated
components.
- Do not build fixed-pixel layouts without responsive behavior.
## Security
- Treat all external data as potentially unsafe.
- Never trust user input without validation.
- Do not store secrets in the frontend or repository.
- Use `.env.example` as the documented template; keep real environment-specific
values out of version control.
- Enforce roles and permissions server-side; use frontend checks only for UX.
- Render HTML from APIs only after controlled sanitization.
- Use `bypassSecurityTrustHtml` only after an explicit security review.
- Check redirect URLs against an allowlist.
- Protect auth flows against XSS and CSRF risks.
- Store tokens only with a deliberate security concept.
## Performance
- Lazy-load larger features.
- Use `OnPush` to reduce unnecessary change detection.
- Always render lists with stable `track` values.
- Consider virtual scrolling for large lists.
- Do not run expensive calculations in templates.
- Move expensive calculations into computed Signals, memoization, or services.
- Do not add a large dependency for a small helper function.
- Check value, risk, and bundle cost before adding a dependency.
- Optimize images and assets.
- Measure performance problems before implementing complex optimizations.
## Tests
- Keep tests next to the code under test with `*.spec.ts`.
- Test services that contain domain logic.
- Test complex component logic.
- Cover critical user flows with E2E tests when an E2E setup exists or is
requested.
- Mock external dependencies in unit tests.
- Do not use real HTTP calls in unit tests.
- Write tests with a clear domain-level assertion.
- Test edge cases and failure cases.
- Do not couple tests unnecessarily to implementation details.
- Test names should describe behavior rather than implementation.
## Code Quality and Project Practice
- Remove unused code and unused imports.
- Do not leave commented-out code blocks in the repository.
- Leave TODOs only with context, a ticket, or a documented decision.
- Use comments sparingly and only for context that is not obvious from the code.
- Keep files small enough to understand quickly.
- Follow existing project conventions.
- Prefer small, focused pull requests.
- Write commit messages with a concrete statement, for example `fix:` or
`improve:` followed by a short summary.
- Do not write commits named only `fix`, `stuff`, `update`, or `changes`.
- Document workarounds when they are unavoidable.
+37
View File
@@ -0,0 +1,37 @@
ARG VCS_REF=unknown
ARG APP_VERSION=unknown
FROM node:24.15.0-bookworm-slim AS build
WORKDIR /app
ENV HUSKY=0
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM node:24.15.0-bookworm-slim AS runtime
ARG VCS_REF
ARG APP_VERSION
WORKDIR /app
ENV NODE_ENV=production
LABEL org.opencontainers.image.title="Personal Portfolio Website"
LABEL org.opencontainers.image.description="👋 My portfolio website built with Angular v22 — a concise introduction to who I am and what I build."
LABEL org.opencontainers.image.licenses="GPL-3.0-only"
LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.revision="${VCS_REF}"
LABEL org.opencontainers.image.source="https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website"
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node scripts/static-server.mjs ./scripts/static-server.mjs
USER node
EXPOSE 3000
CMD ["node", "scripts/static-server.mjs"]
+577 -420
View File
File diff suppressed because it is too large. Load diff
+50 -35
View File
@@ -1,60 +1,75 @@
# 🖼️ FrauJulian's Portfolio Website
# FrauJulian's Portfolio Website
### [🙋‍♂️ Website Preview](https://fraujulian.xyz/)
![grafik](https://github.com/user-attachments/assets/65f1e491-62ab-4b16-a39c-5f04377ce5cb)
## Preview
## 👂 Languages/Framework/Packages:
- Angular v19
Visit the live website at [fraujulian.xyz](https://fraujulian.xyz/).
## Technology
- Angular 22
- TypeScript
- SCSS
- Vitest for tests
## 💻 Development
## Features
### Development Preview
- English and German language options (`?lang=de` selects German)
- About, work, and project portfolio
- Contact links
- Imprint and privacy information
- Interactive portrait highlights
## Project Architecture
- `src/app/home` — portfolio landing page
- `src/app/imprint` — imprint and privacy page
- `src/app/footer` — site footer
- `src/app/shared` — reusable UI components
- `src/app/services` — language and preference services
- `src/languages` — English and German content
- `scripts` — asset generation, versioning, and static file tools
## Setup
Requirements: Node.js 24.15 or newer and npm 11.12 or newer.
### Development
```bash
npm ci
npm run dev
```
### Building
The development server generates required static assets and runs the site at
`http://localhost:4200`.
### Production
```bash
npm run buildDevelopment
npm run build
npm start
```
or for Production Build
The build writes the static site to `dist/browser`. The static server serves the
built site at `http://localhost:3000`.
## Tests
```bash
npm run buildProduction
npm run test
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
This generates assets and runs the Vitest tests in jsdom.
### Start compiled Project
## Docs
```bash
npm run start
```
- [Nginx Proxy Manager deployment notes](npm.md)
## Running unit tests
## Guidelines
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
Run `npm run check` before submitting changes. See [AGENTS.md](AGENTS.md) for
project conventions.
```bash
nom run test
```
## LICENSE
## Update Packages/Libraries
```bash
npm run updatePackageVersions
```
## 📋 Credits:
~ made by [**FrauJulian**](https://fraujulian.xyz/).
## 🤝 Enjoy?
Give it a star ⭐ on [github](https://github.com/FrauJulian/discord-audio-stream)!
### Greetings from Austria! ⛰️
Licensed under the [GNU Affero General Public License v3.0](LICENSE).
+75 -39
View File
@@ -15,32 +15,41 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"outputPath": {
"base": "dist",
"browser": "browser",
"media": "media"
},
"outputMode": "static",
"index": {
"input": "src/index.html",
"output": "index.html",
"preloadInitial": true
},
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"server": false,
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"stylePreprocessorOptions": {
"includePaths": ["src"]
},
"security": {
"autoCsp": true
},
"clearScreen": false,
"assets": [
{
"glob": "**/*",
"input": "public"
"input": "src/assets/optimized",
"output": "assets/optimized"
}
],
"styles": [
"node_modules/@angular/material/prebuilt-themes/rose-red.css",
"src/styles.scss"
],
"styles": ["src/styles.scss"],
"scripts": [],
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "src/server.ts"
}
"serviceWorker": false
},
"configurations": {
"production": {
@@ -48,13 +57,19 @@
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": true
"inlineCritical": true,
"removeSpecialComments": true
},
"fonts": {
"inline": true
}
},
"outputHashing": "all",
"crossOrigin": "anonymous",
"subresourceIntegrity": true,
"sourceMap": false,
"namedChunks": false,
"extractLicenses": false,
"extractLicenses": true,
"aot": true,
"budgets": [
{
@@ -66,20 +81,30 @@
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
},
{
"type": "anyScript",
"maximumWarning": "350kB",
"maximumError": "500kB"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"sourceMap": {
"scripts": true,
"styles": true,
"vendor": false,
"sourcesContent": true
},
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"options": {
"port": 4200
},
@@ -94,29 +119,14 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:unit-test",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"buildTarget": "::development",
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public",
"output": "public"
}
],
"styles": [
"node_modules/@angular/material/prebuilt-themes/rose-red.css",
"src/styles.scss"
],
"scripts": []
"watch": false
}
}
}
@@ -124,5 +134,31 @@
},
"cli": {
"analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"Server": {
"Port": 3004
}
}
+9
View File
@@ -0,0 +1,9 @@
services:
portfolio:
image: docker.lechner-systems.at/lechnersystems/portfolio:${IMAGE_TAG:-latest}
restart: unless-stopped
dns:
- 192.168.100.2
- 192.168.100.6
ports:
- '${MAIN_PORT:-3000}:3000'
+114
View File
@@ -0,0 +1,114 @@
const angularEslint = require('angular-eslint');
/** @type {import('eslint').Linter.FlatConfig[]} */
const config = [
{
ignores: ['dist/**', 'node_modules/**', '.angular/**', 'backend/sql/**'],
},
{
files: ['**/*.{ts,tsx,js,cjs,mjs}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: require('@typescript-eslint/parser'),
parserOptions: {
ecmaFeatures: {},
},
},
plugins: {
'@angular-eslint': angularEslint.tsPlugin,
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
import: require('eslint-plugin-import'),
promise: require('eslint-plugin-promise'),
'unused-imports': require('eslint-plugin-unused-imports'),
n: require('eslint-plugin-n'),
},
rules: {
// Your selectors
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
// Practical TS hygiene
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
/* Base */
'no-debugger': 'warn',
'no-var': 'error',
'prefer-const': ['error', { destructuring: 'all' }],
/* TypeScript */
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'error',
/* Imports */
'import/first': 'error',
'import/no-duplicates': 'error',
'import/newline-after-import': 'warn',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'**/*.config*.{js,cjs,mjs,ts}',
'**/scripts/**',
'**/*.test.{ts,js}',
'**/Testing.{ts,js}',
'**/.eslintrc.{js,cjs}',
],
},
],
/* Node */
'n/no-missing-import': 'off',
'n/no-unsupported-features/es-syntax': 'off',
/* Promis */
'promise/catch-or-return': 'warn',
'promise/always-return': 'off',
/* „Fire-and-forget“-Promises `void` */
'no-void': ['warn', { allowAsStatement: true }],
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
node: true,
},
},
},
{
files: ['**/*.{js,cjs}'],
languageOptions: {
sourceType: 'script',
},
rules: {
'n/global-require': 'off',
},
},
];
module.exports = config;
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('eslint').Linter.FlatConfig[]} */
const base = require('./eslint.config.js');
const typeAwareLayer = {
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: require('@typescript-eslint/parser'),
parserOptions: {
project: ['./tsconfig.eslint.json', './tsconfig.spec.json', './backend/tsconfig.json'],
tsconfigRootDir: __dirname,
},
},
plugins: {
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
},
};
module.exports = [...base, typeAwareLayer];
+81
View File
@@ -0,0 +1,81 @@
# Nginx Proxy Manager Notes
## Custom Location
Create a new custom location with these values:
- Location: `~*`
- Path: `\.(?:js|css|mjs|webp|avif|png|jpg|jpeg|gif|svg|ico|woff2?)$`
- Scheme/Hostname/Port: `same as Origin`
Use the following extra options:
```nginx
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Vary "Accept-Encoding" always;
```
## General Configuration
Use the following general proxy configuration:
```nginx
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 32 16k;
proxy_busy_buffers_size 64k;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/manifest+json
application/rss+xml
application/vnd.ms-fontobject
application/wasm
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
application/javascript
application/json
application/manifest+json
application/wasm
application/xml
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header Vary "Accept-Encoding" always;
```
+7632 -10811
View File
File diff suppressed because it is too large. Load diff
+76 -51
View File
@@ -1,65 +1,90 @@
{
"name": "angular-portfolio-seite",
"version": "3.1.11",
"main": "src/server.ts",
"scripts": {
"dev": "npm run updateProjectVersion && ng serve",
"test": "ng test",
"clean": "del-cli dist && echo SUCCEED CLEAN",
"updatePackageVersions": "ng update @angular/cli @angular/core && ng update && npm update",
"updateProjectVersion": "npm version patch --no-git-tag-version && echo SUCCEED UPDATE VERSION",
"buildDevelopment": "npm run clean && ng build --configuration development && echo SUCCEED DEV BUILD && npm run updateProjectVersion",
"buildProduction": "npm run clean && ng build --configuration production && echo SUCCEED PROD BUILD && npm run updateProjectVersion",
"start": "node dist/server/server.mjs"
"name": "portfolio",
"version": "YYDDDhhmm+GIT_HASH",
"currentVersion": "3.2.0",
"engines": {
"node": ">=24.15.0",
"npm": ">=11.12.0"
},
"repository": "https://github.com/FrauJulian/Personal-Portfolio-Website",
"bugs": "https://github.com/FrauJulian/Personal-Portfolio-Website/issues",
"scripts": {
"------ UTILS": "",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "npm run lint:base && npm run lint:types",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs --fix",
"lint:base": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs",
"lint:types": "eslint . --config eslint.config.typeaware.cjs --ext .ts,.tsx",
"text:check": "node scripts/check-source-quality.cjs",
"text:fix": "node scripts/fix-source-text.cjs --write",
"version:set": "node ./scripts/version.cjs --set",
"version:ensure": "node ./scripts/version.cjs --ensure",
"version:check": "node ./scripts/version.cjs --check",
"version:place": "node ./scripts/version.cjs --place",
"------ CI": "",
"check": "npm run lint && npm run format:check && npm run version:check && npm run text:check",
"fix": "npm run version:place && npm run lint:fix && npm run text:fix && npm run format",
"------ BUILD": "",
"generate:static-files": "node scripts/generate-static-files.cjs",
"generate:optimized-assets": "node scripts/generate-optimized-assets.cjs",
"generate:assets": "npm run generate:static-files && npm run generate:optimized-assets",
"build": "npm run generate:assets && ng build --configuration production && node scripts/copy-static-root-files.cjs",
"------ STARTUP": "",
"test": "npm run generate:assets && ng test",
"dev": "npm run generate:assets && ng serve",
"start": "node scripts/static-server.mjs",
"------ CONFIGS": "",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,js}": [
"prettier --write",
"eslint --fix"
],
"*.{html,css,scss,json,md}": [
"prettier --write"
]
},
"repository": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website",
"bugs": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website/issues",
"author": {
"name": "Lechner Julian",
"nickname": "FrauJulian",
"email": "contact@fraujulian.xyz",
"email": "fraujulian@lechner.top",
"website": "https://fraujulian.xyz/"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.19",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/platform-server": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/ssr": "^19.2.9",
"@fortawesome/angular-fontawesome": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"express": "^4.18.2",
"markdown-it": "^14.1.0",
"@angular/common": "^22.2.1",
"@angular/compiler": "^22.2.1",
"@angular/core": "^22.2.1",
"@angular/platform-browser": "^22.2.1",
"@angular/router": "^22.2.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.9",
"@angular/cli": "^19.2.9",
"@angular/compiler-cli": "^19.2.0",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0",
"@types/markdown-it": "^14.1.2",
"@types/node": "^18.18.0",
"@types/showdown": "^2.0.6",
"del-cli": "^6.0.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.1",
"@types/node": "^25.7.0",
"@typescript-eslint/eslint-plugin": "8.71.1",
"@typescript-eslint/parser": "8.71.1",
"angular-eslint": "22.5.0",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "^18.4.1",
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-unused-imports": "4.4.1",
"husky": "^9.1.7",
"jsdom": "^30.1.2",
"postcss": "^8.5.29",
"postcss-selector-parser": "^7.1.1",
"prettier": "^3.8.1",
"sharp": "^0.35.5",
"typescript": "~6.0.3",
"typescript-eslint": "8.71.1",
"vitest": "^5.0.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 873 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const {
PROJECT_ROOT,
DEFAULT_ALLOWED_EXTENSIONS,
collectSourceFiles,
toProjectRelativePath,
} = require('./source-files.cjs');
const SOURCE_DIRECTORIES = [
path.join(PROJECT_ROOT, 'backend', 'src'),
path.join(PROJECT_ROOT, 'src'),
path.join(PROJECT_ROOT, 'scripts'),
];
const IGNORED_RELATIVE_PATHS = new Set([
'scripts/check-source-quality.cjs',
'scripts/fix-source-text.cjs',
]);
const ALLOWED_EXTENSIONS = DEFAULT_ALLOWED_EXTENSIONS;
const MOJIBAKE_PATTERNS = [
{ label: 'UTF8_AS_LATIN1', regex: /\u00C3[\u0080-\u00BF]/u },
{ label: 'REPLACEMENT_CHAR', regex: /\uFFFD/u },
{ label: 'MISDECODED_QUESTION_MARK', regex: /\u00EF\u00BF\u00BD/u },
{ label: 'CP1252_ARTIFACT', regex: /\u00C2[\u0080-\u00BF]?/u },
{
label: 'DOUBLE_DECODED_APOSTROPHE',
regex: /\u00E2\u20AC\u2122|\u00E2\u20AC\u0153|\u00E2\u20AC\u009D/u,
},
];
const ASCII_TRANSLITERATION_PATTERNS = [
{
label: 'ASCII_TRANSLITERATION',
regex:
/\b(?:fuer|Fuer|gueltig|Gueltig|ungueltig|Ungueltig|zurueck|Zurueck|ueberein|Ueberein|waehlen|Waehlen|zaehler|Zaehler|ausfuellen|Ausfuellen|geschuetzt|Geschuetzt|verschluesselt|Verschluesselt)\b/u,
},
];
const VAR_PATTERN = /\bvar\s+[A-Za-z_$][A-Za-z0-9_$]*/u;
/** @type {Array<{file: string, rule: string, detail: string}>} */
const issues = [];
for (const directoryPath of SOURCE_DIRECTORIES) {
const files = collectSourceFiles(directoryPath, ALLOWED_EXTENSIONS);
for (const filePath of files) {
const source = fs.readFileSync(filePath, 'utf8');
const relativePath = toProjectRelativePath(filePath);
const extension = path.extname(filePath).toLowerCase();
if (IGNORED_RELATIVE_PATHS.has(relativePath)) {
continue;
}
for (const pattern of MOJIBAKE_PATTERNS) {
if (pattern.regex.test(source)) {
issues.push({
file: relativePath,
rule: pattern.label,
detail: 'possible mojibake detected',
});
}
}
for (const pattern of ASCII_TRANSLITERATION_PATTERNS) {
if (pattern.regex.test(source)) {
issues.push({
file: relativePath,
rule: pattern.label,
detail: 'possible ASCII transliteration detected',
});
}
}
if (extension !== '.html' && VAR_PATTERN.test(source)) {
issues.push({
file: relativePath,
rule: 'NO_VAR',
detail: 'use const/let instead of var',
});
}
}
}
if (issues.length === 0) {
console.log('Source quality check passed: no mojibake artifacts and no var declarations found.');
process.exit(0);
}
console.error('Source quality check failed:');
for (const issue of issues) {
console.error(`- ${issue.file} [${issue.rule}] ${issue.detail}`);
}
process.exit(1);
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const sources = [
{
source: path.join(process.cwd(), 'src', 'robots.txt'),
fileName: 'robots.txt',
},
{
source: path.join(process.cwd(), 'src', 'sitemap.xml'),
fileName: 'sitemap.xml',
},
];
const distRoot = path.join(process.cwd(), 'dist');
const browserDir = path.join(distRoot, 'browser');
const targetDirectories = [distRoot, browserDir]
.filter((targetDirectory) => fs.existsSync(targetDirectory))
.filter((targetDirectory, index, list) => list.indexOf(targetDirectory) === index);
if (targetDirectories.length === 0) {
console.warn('Skipping static root file copy: dist directory not found.');
process.exit(0);
}
for (const { source, fileName } of sources) {
if (!fs.existsSync(source)) {
console.warn(`Skipping ${fileName} copy: source file not found.`);
continue;
}
for (const targetDirectory of targetDirectories) {
const targetFile = path.join(targetDirectory, fileName);
fs.copyFileSync(source, targetFile);
console.log(
`Copied ${path.relative(process.cwd(), source)} -> ${path.relative(process.cwd(), targetFile)}`,
);
}
}
+107
View File
@@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
const ENV_FILE_CANDIDATES = [
path.join(process.cwd(), '.env'),
path.join(process.cwd(), 'backend', '.env'),
];
function applyEnvFile(filePath, env = process.env) {
const rawFile = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/u, '');
const lines = rawFile.split(/\r?\n/u);
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
const withoutExport = trimmedLine.startsWith('export ')
? trimmedLine.slice('export '.length).trim()
: trimmedLine;
const separatorIndex = withoutExport.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = withoutExport.slice(0, separatorIndex).trim();
let value = withoutExport.slice(separatorIndex + 1).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || key in env) {
continue;
}
if (value.startsWith('"') && value.endsWith('"')) {
value = value
.slice(1, -1)
.replace(/\\n/gu, '\n')
.replace(/\\r/gu, '\r')
.replace(/\\t/gu, '\t')
.replace(/\\"/gu, '"')
.replace(/\\\\/gu, '\\');
} else if (value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
} else {
value = value.replace(/\s+#.*$/u, '').trim();
}
env[key] = value;
}
}
function loadEnvFromFileCandidates(envFileCandidates = ENV_FILE_CANDIDATES, env = process.env) {
for (const envFilePath of envFileCandidates) {
if (!fs.existsSync(envFilePath)) {
continue;
}
applyEnvFile(envFilePath, env);
return envFilePath;
}
return null;
}
function parsePositiveIntEnv(names, fallback, env = process.env) {
const candidates = Array.isArray(names) ? names : [names];
for (const name of candidates) {
const rawValue = env[name];
if (rawValue === undefined || rawValue === null || rawValue.trim().length === 0) {
continue;
}
const parsed = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer. Received "${rawValue}".`);
}
return parsed;
}
return fallback;
}
function parseTimeZoneEnv(name, fallback, env = process.env) {
const rawValue = env[name];
const value =
rawValue === undefined || rawValue === null || rawValue.trim().length === 0
? fallback
: rawValue.trim();
try {
new Intl.DateTimeFormat('en-GB', {
timeZone: value,
});
} catch {
throw new Error(`${name} must be a valid IANA timezone. Received "${rawValue}".`);
}
return value;
}
module.exports = {
loadEnvFromFileCandidates,
parsePositiveIntEnv,
parseTimeZoneEnv,
};
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const {
PROJECT_ROOT,
DEFAULT_ALLOWED_EXTENSIONS,
collectSourceFiles,
toProjectRelativePath,
} = require('./source-files.cjs');
const SOURCE_DIRECTORIES = [
path.join(PROJECT_ROOT, 'backend', 'src'),
path.join(PROJECT_ROOT, 'src'),
];
const ALLOWED_EXTENSIONS = DEFAULT_ALLOWED_EXTENSIONS;
const WRITE_MODE = process.argv.includes('--write');
const DIRECT_REPLACEMENTS = [
['Ä', 'Ä'],
['Ö', 'Ö'],
['Ü', 'Ü'],
['ä', 'ä'],
['ö', 'ö'],
['ü', 'ü'],
['ß', 'ß'],
['’', '’'],
['“', '“'],
['”', '”'],
['–', '–'],
['—', '—'],
['…', '…'],
['§', '§'],
['&Auml;', 'Ä'],
['&Ouml;', 'Ö'],
['&Uuml;', 'Ü'],
['&auml;', 'ä'],
['&ouml;', 'ö'],
['&uuml;', 'ü'],
['&szlig;', 'ß'],
['&sect;', '§'],
];
const WORD_REPLACEMENTS = [
[/\bAufrufzaehler\b/gu, 'Aufrufzähler'],
[/\baufzaehlen\b/gu, 'aufzählen'],
[/\bAusfuellen\b/gu, 'Ausfüllen'],
[/\bausfuellen\b/gu, 'ausfüllen'],
[/\bAuswaehlen\b/gu, 'Auswählen'],
[/\bauswaehlen\b/gu, 'auswählen'],
[/\bEnthaelt\b/gu, 'Enthält'],
[/\benthaelt\b/gu, 'enthält'],
[/\bFuer\b/gu, 'Für'],
[/\bfuer\b/gu, 'für'],
[/\bGeschuetzt\b/gu, 'Geschützt'],
[/\bgeschuetzt\b/gu, 'geschützt'],
[/\bGueltig\b/gu, 'Gültig'],
[/\bgueltig\b/gu, 'gültig'],
[/\bOesterreich\b/gu, 'Österreich'],
[/\boesterreich\b/gu, 'österreich'],
[/\bUeberein\b/gu, 'Überein'],
[/\bueberein\b/gu, 'überein'],
[/\bUngueltig\b/gu, 'Ungültig'],
[/\bungueltig\b/gu, 'ungültig'],
[/\bVerschluesselt\b/gu, 'Verschlüsselt'],
[/\bverschluesselt\b/gu, 'verschlüsselt'],
[/\bWaehlen\b/gu, 'Wählen'],
[/\bwaehlen\b/gu, 'wählen'],
[/\bZaehler\b/gu, 'Zähler'],
[/\bzaehler\b/gu, 'zähler'],
[/\bZurueck\b/gu, 'Zurück'],
[/\bzurueck\b/gu, 'zurück'],
];
function applyTextFixes(source) {
let nextSource = source;
for (const [searchValue, replaceValue] of DIRECT_REPLACEMENTS) {
if (nextSource.includes(searchValue)) {
nextSource = nextSource.split(searchValue).join(replaceValue);
}
}
for (const [pattern, replaceValue] of WORD_REPLACEMENTS) {
nextSource = nextSource.replace(pattern, replaceValue);
}
return nextSource;
}
const changedFiles = [];
for (const directoryPath of SOURCE_DIRECTORIES) {
for (const filePath of collectSourceFiles(directoryPath, ALLOWED_EXTENSIONS)) {
const source = fs.readFileSync(filePath, 'utf8');
const nextSource = applyTextFixes(source);
if (nextSource === source) {
continue;
}
changedFiles.push(toProjectRelativePath(filePath));
if (WRITE_MODE) {
fs.writeFileSync(filePath, nextSource, 'utf8');
}
}
}
if (changedFiles.length === 0) {
console.log(`Source text fixer: no changes ${WRITE_MODE ? 'written' : 'needed'}.`);
process.exit(0);
}
const actionLabel = WRITE_MODE ? 'updated' : 'would update';
console.log(`Source text fixer ${actionLabel} ${changedFiles.length} file(s):`);
for (const filePath of changedFiles) {
console.log(`- ${filePath}`);
}
process.exit(WRITE_MODE ? 0 : 1);
+219
View File
@@ -0,0 +1,219 @@
const fs = require('node:fs');
const path = require('node:path');
const sharp = require('sharp');
const sourceRoot = path.join(process.cwd(), 'src', 'assets', 'unoptimized');
const targetRoot = path.join(process.cwd(), 'src', 'assets', 'optimized');
const rasterExtensions = new Set([
'.jpg',
'.jpeg',
'.png',
'.webp',
'.avif',
'.gif',
'.tif',
'.tiff',
]);
const passthroughExtensions = new Set(['.svg', '.woff2']);
function collectFilesRecursively(directoryPath) {
if (!fs.existsSync(directoryPath)) {
return [];
}
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectFilesRecursively(fullPath));
continue;
}
if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}
function ensureCleanDirectory(directoryPath) {
fs.rmSync(directoryPath, { recursive: true, force: true });
fs.mkdirSync(directoryPath, { recursive: true });
}
function getProfile(relativePath) {
const normalizedRelativePath = relativePath.split(path.sep).join('/').toLowerCase();
if (normalizedRelativePath === 'background.jpg' || normalizedRelativePath === 'background.jpeg') {
return { maxWidth: 1920, quality: 82, alphaQuality: 100 };
}
if (normalizedRelativePath.startsWith('logos/')) {
return { maxWidth: 216, quality: 78, alphaQuality: 88, variants: [64, 128, 216] };
}
if (normalizedRelativePath.startsWith('portrait/')) {
return { maxWidth: 640, quality: 82, alphaQuality: 90, variants: [128, 320, 384, 480] };
}
return { maxWidth: 1600, quality: 82, alphaQuality: 92 };
}
function getTargetRelativePath(relativePath, width) {
const parsedPath = path.parse(relativePath);
const fileName =
width === undefined ? `${parsedPath.name}.webp` : `${parsedPath.name}-${width}.webp`;
return path.join(parsedPath.dir, fileName);
}
async function createWebpBuffer(inputBuffer, profile, width) {
const image = sharp(inputBuffer, { animated: false }).rotate();
const metadata = await image.metadata();
const targetWidth = width ?? profile.maxWidth;
let pipeline = image;
if ((metadata.width ?? 0) > targetWidth) {
pipeline = pipeline.resize({
width: targetWidth,
fit: 'inside',
withoutEnlargement: true,
});
}
return pipeline
.webp({
quality: profile.quality,
alphaQuality: profile.alphaQuality,
effort: 6,
smartSubsample: true,
nearLossless: profile.nearLossless ?? false,
})
.toBuffer();
}
async function writeOptimizedAsset(sourceFilePath) {
const relativePath = path.relative(sourceRoot, sourceFilePath);
const extension = path.extname(sourceFilePath).toLowerCase();
const targetRelativePath = passthroughExtensions.has(extension)
? relativePath
: getTargetRelativePath(relativePath);
const targetFilePath = path.join(targetRoot, targetRelativePath);
fs.mkdirSync(path.dirname(targetFilePath), { recursive: true });
if (passthroughExtensions.has(extension)) {
fs.copyFileSync(sourceFilePath, targetFilePath);
return {
sourceRelativePath: relativePath,
targetRelativePath,
beforeBytes: fs.statSync(sourceFilePath).size,
afterBytes: fs.statSync(targetFilePath).size,
mode: 'copied',
};
}
const profile = getProfile(relativePath);
const inputBuffer = fs.readFileSync(sourceFilePath);
const inputBytes = inputBuffer.byteLength;
const outputBuffer = await createWebpBuffer(inputBuffer, profile);
fs.writeFileSync(targetFilePath, outputBuffer);
for (const variantWidth of profile.variants ?? []) {
const variantRelativePath = getTargetRelativePath(relativePath, variantWidth);
const variantFilePath = path.join(targetRoot, variantRelativePath);
const variantBuffer = await createWebpBuffer(inputBuffer, profile, variantWidth);
fs.writeFileSync(variantFilePath, variantBuffer);
}
return {
sourceRelativePath: relativePath,
targetRelativePath,
beforeBytes: inputBytes,
afterBytes: outputBuffer.byteLength,
mode: 'converted',
};
}
const iconSourcePath = path.join(sourceRoot, 'portrait', 'me.png');
const iconOutputs = [
{ fileName: 'icons/favicon-32.png', width: 32, height: 32, fit: 'cover', format: 'png' },
{ fileName: 'icons/apple-touch-icon.png', width: 180, height: 180, fit: 'cover', format: 'png' },
{ fileName: 'icons/og-image.jpg', width: 1200, height: 630, fit: 'contain', format: 'jpeg' },
];
// Favicons and social previews need PNG/JPEG because not every client accepts WebP.
async function writeIconAssets() {
if (!fs.existsSync(iconSourcePath)) {
console.warn('Icon generation skipped: portrait/me.png not found.');
return;
}
for (const { fileName, width, height, fit, format } of iconOutputs) {
const targetFilePath = path.join(targetRoot, fileName);
fs.mkdirSync(path.dirname(targetFilePath), { recursive: true });
await sharp(iconSourcePath)
.resize({ width, height, fit, position: 'top', background: '#08080b' })
.flatten({ background: '#08080b' })
.toFormat(format, { quality: 86 })
.toFile(targetFilePath);
console.log(`Generated: ${fileName}`);
}
}
async function run() {
if (!fs.existsSync(sourceRoot)) {
console.log('Optimized asset generation skipped: no unoptimized asset directory found.');
return;
}
ensureCleanDirectory(targetRoot);
const assetFiles = collectFilesRecursively(sourceRoot).filter((filePath) => {
const extension = path.extname(filePath).toLowerCase();
return rasterExtensions.has(extension) || passthroughExtensions.has(extension);
});
let convertedCount = 0;
let copiedCount = 0;
let totalSavedBytes = 0;
for (const assetFile of assetFiles) {
const result = await writeOptimizedAsset(assetFile);
const savedBytes = result.beforeBytes - result.afterBytes;
totalSavedBytes += savedBytes;
if (result.mode === 'copied') {
copiedCount += 1;
console.log(
`Copied: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${result.afterBytes} bytes)`,
);
continue;
}
convertedCount += 1;
console.log(
`Optimized: ${result.sourceRelativePath} -> ${result.targetRelativePath} (${savedBytes >= 0 ? `-${savedBytes}` : `+${Math.abs(savedBytes)}`} bytes)`,
);
}
await writeIconAssets();
console.log(
`Optimized asset generation complete. Converted: ${convertedCount}, copied: ${copiedCount}, net saved: ${totalSavedBytes} bytes.`,
);
}
run()
.then(() => {
process.exit(0);
})
.catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
console.error(`Optimized asset generation failed: ${message}`);
process.exit(1);
});
+117
View File
@@ -0,0 +1,117 @@
const fs = require('fs');
const path = require('path');
const { loadEnvFromFileCandidates } = require('./env-utils.cjs');
const staticSiteConfig = require('./static-site.config.cjs');
loadEnvFromFileCandidates();
const outputDir = path.join(process.cwd(), 'src');
const robotsPath = path.join(outputDir, 'robots.txt');
const sitemapPath = path.join(outputDir, 'sitemap.xml');
const configuredSiteUrl = process.env.PUBLIC_SITE_URL || staticSiteConfig.siteUrl;
const siteUrl = normalizeSiteUrl(configuredSiteUrl);
if (!siteUrl) {
throw new Error(
'Missing site URL for static file generation. Set PUBLIC_SITE_URL or scripts/static-site.config.cjs.',
);
}
const robotsContent = createRobotsContent(staticSiteConfig.robots, siteUrl);
const sitemapContent = createSitemapContent(staticSiteConfig.sitemap.routes, siteUrl);
fs.writeFileSync(robotsPath, `${robotsContent}\n`, 'utf8');
fs.writeFileSync(sitemapPath, `${sitemapContent}\n`, 'utf8');
console.log(`Generated ${path.relative(process.cwd(), robotsPath)}`);
console.log(`Generated ${path.relative(process.cwd(), sitemapPath)}`);
function normalizeSiteUrl(rawSiteUrl) {
if (typeof rawSiteUrl !== 'string') {
return '';
}
const trimmedSiteUrl = rawSiteUrl.trim().replace(/\/+$/u, '');
if (!trimmedSiteUrl) {
return '';
}
try {
const normalizedUrl = new URL(trimmedSiteUrl);
return normalizedUrl.toString().replace(/\/+$/u, '');
} catch {
throw new Error(`Invalid site URL "${rawSiteUrl}" in static site configuration.`);
}
}
function createRobotsContent(robotsConfig, resolvedSiteUrl) {
const lines = ['User-agent: *', ''];
for (const allowedPath of robotsConfig.allow) {
lines.push(`Allow: ${allowedPath}`);
}
for (const disallowedPath of robotsConfig.disallow) {
lines.push(`Disallow: ${disallowedPath}`);
}
lines.push('', `Sitemap: ${resolvedSiteUrl}/sitemap.xml`);
return lines.join('\n');
}
function createSitemapContent(routes, resolvedSiteUrl) {
const entries = routes
.map((route) => {
const normalizedPath = normalizeRoutePath(route.path);
const parts = [
' <url>',
` <loc>${escapeXml(`${resolvedSiteUrl}${normalizedPath}`)}</loc>`,
];
if (route.lastModified) {
parts.push(` <lastmod>${escapeXml(route.lastModified)}</lastmod>`);
}
if (route.changeFrequency) {
parts.push(` <changefreq>${escapeXml(route.changeFrequency)}</changefreq>`);
}
if (route.priority) {
parts.push(` <priority>${escapeXml(route.priority)}</priority>`);
}
parts.push(' </url>');
return parts.join('\n');
})
.join('\n');
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
entries,
'</urlset>',
].join('\n');
}
function normalizeRoutePath(routePath) {
if (routePath === '/') {
return '';
}
if (typeof routePath !== 'string' || !routePath.startsWith('/')) {
throw new Error(`Invalid sitemap route "${routePath}". Routes must start with "/".`);
}
return routePath;
}
function escapeXml(value) {
return value
.replace(/&/gu, '&amp;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&apos;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;');
}
+63
View File
@@ -0,0 +1,63 @@
const fs = require('node:fs');
const path = require('node:path');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const DEFAULT_ALLOWED_EXTENSIONS = new Set(['.ts', '.js', '.cjs', '.html', '.css']);
/**
* @param {string} directoryPath
* @param {Set<string>} [allowedExtensions]
* @returns {string[]}
*/
function collectSourceFiles(directoryPath, allowedExtensions = DEFAULT_ALLOWED_EXTENSIONS) {
/** @type {string[]} */
const results = [];
if (!fs.existsSync(directoryPath)) {
return results;
}
/** @type {string[]} */
const queue = [directoryPath];
while (queue.length > 0) {
const currentPath = queue.pop();
if (!currentPath) {
continue;
}
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
queue.push(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const extension = path.extname(entry.name).toLowerCase();
if (allowedExtensions.has(extension)) {
results.push(absolutePath);
}
}
}
return results;
}
/**
* @param {string} absolutePath
* @returns {string}
*/
function toProjectRelativePath(absolutePath) {
return path.relative(PROJECT_ROOT, absolutePath).split(path.sep).join('/');
}
module.exports = {
PROJECT_ROOT,
DEFAULT_ALLOWED_EXTENSIONS,
collectSourceFiles,
toProjectRelativePath,
};
+218
View File
@@ -0,0 +1,218 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { createBrotliCompress, createGzip } from 'node:zlib';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const appRoot = path.resolve(__dirname, '..');
const distRoot = path.join(appRoot, 'dist');
const browserRoot = path.join(distRoot, 'browser');
const port = 3000;
const indexPath = path.join(browserRoot, 'index.html');
// Keep in sync with src/app/app.routes.ts.
const appRoutes = new Set(['/', '/imprint']);
const mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.jpg', 'image/jpeg'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.map', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.webp', 'image/webp'],
['.woff2', 'font/woff2'],
['.xml', 'application/xml; charset=utf-8'],
]);
const compressibleExtensions = new Set([
'.css',
'.html',
'.js',
'.json',
'.map',
'.svg',
'.txt',
'.xml',
]);
const securityHeaders = {
'Permissions-Policy': 'camera=(), geolocation=(), microphone=()',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Strict-Transport-Security': 'max-age=31536000',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
};
function getContentType(filePath) {
return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream';
}
function getCacheControl(filePath) {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = path.basename(filePath);
const extension = path.extname(fileName).toLowerCase();
if (extension === '.html') {
return 'no-cache, must-revalidate';
}
if (/\-[A-Z0-9]{8,}\.(css|js)$/i.test(fileName)) {
return 'public, max-age=31536000, immutable';
}
if (
[
'.css',
'.ico',
'.jpg',
'.js',
'.json',
'.map',
'.png',
'.svg',
'.txt',
'.webp',
'.woff2',
'.xml',
].includes(extension)
) {
return normalizedPath.includes('/browser/')
? 'public, max-age=604800, stale-while-revalidate=86400'
: 'public, max-age=86400, stale-while-revalidate=3600';
}
return 'public, max-age=3600';
}
async function isFile(targetPath) {
try {
return (await stat(targetPath)).isFile();
} catch {
return false;
}
}
function resolveInside(root, relativePath) {
const candidate = path.resolve(root, relativePath);
return candidate === root || candidate.startsWith(`${root}${path.sep}`) ? candidate : null;
}
async function resolveFile(urlPath) {
const normalized = path.posix.normalize(decodeURIComponent(urlPath));
const relativePath = normalized.replace(/^\/+/, '');
for (const root of [distRoot, browserRoot]) {
const candidate = resolveInside(root, relativePath);
if (candidate === null) {
return null;
}
if (await isFile(candidate)) {
return candidate;
}
}
return null;
}
function getEncoding(request, filePath) {
if (!compressibleExtensions.has(path.extname(filePath).toLowerCase())) {
return null;
}
const acceptEncoding = request.headers['accept-encoding'] ?? '';
if (/\bbr\b/.test(acceptEncoding)) {
return 'br';
}
return /\bgzip\b/.test(acceptEncoding) ? 'gzip' : null;
}
async function sendFile(request, response, filePath, statusCode) {
const encoding = getEncoding(request, filePath);
const headers = {
...securityHeaders,
'Cache-Control': statusCode === 200 ? getCacheControl(filePath) : 'no-cache',
'Content-Type': getContentType(filePath),
Vary: 'Accept-Encoding',
};
if (encoding !== null) {
headers['Content-Encoding'] = encoding;
}
response.writeHead(statusCode, headers);
if (request.method === 'HEAD') {
response.end();
return;
}
const source = createReadStream(filePath);
if (encoding === null) {
await pipeline(source, response);
return;
}
await pipeline(source, encoding === 'br' ? createBrotliCompress() : createGzip(), response);
}
function sendText(response, statusCode, message, extraHeaders = {}) {
response.writeHead(statusCode, {
...securityHeaders,
...extraHeaders,
'Content-Type': 'text/plain; charset=utf-8',
});
response.end(message);
}
const server = createServer(async (request, response) => {
try {
if (request.method !== 'GET' && request.method !== 'HEAD') {
sendText(response, 405, 'Method not allowed', { Allow: 'GET, HEAD' });
return;
}
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
const filePath = await resolveFile(requestUrl.pathname);
if (filePath) {
await sendFile(request, response, filePath, 200);
return;
}
const routePath = requestUrl.pathname.replace(/(.)\/+$/, '$1');
if (appRoutes.has(routePath)) {
await sendFile(request, response, indexPath, 200);
return;
}
// Unknown routes get a 404 status but still boot the app, which redirects to the start page.
if (!path.posix.extname(requestUrl.pathname)) {
await sendFile(request, response, indexPath, 404);
return;
}
sendText(response, 404, 'Not found');
} catch (error) {
if (!response.headersSent) {
sendText(response, 500, 'Internal server error');
} else {
response.destroy();
}
console.error(error);
}
});
server.listen(port, '0.0.0.0', () => {
console.log(`Serving static app on port ${port}`);
});
+17
View File
@@ -0,0 +1,17 @@
const lastModified = '2026-10-15';
module.exports = {
siteUrl: 'https://fraujulian.xyz',
sitemap: {
routes: [
{ path: '/', lastModified, changeFrequency: 'monthly', priority: '1.0' },
{ path: '/?lang=de', lastModified, changeFrequency: 'monthly', priority: '1.0' },
{ path: '/imprint', lastModified, changeFrequency: 'yearly', priority: '0.3' },
{ path: '/imprint?lang=de', lastModified, changeFrequency: 'yearly', priority: '0.3' },
],
},
robots: {
allow: ['/'],
disallow: [],
},
};
+199
View File
@@ -0,0 +1,199 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, 'package.json');
const PACKAGE_LOCK_PATH = path.join(PROJECT_ROOT, 'package-lock.json');
const VERSION_PLACEHOLDER = 'YYDDDhhmm+GIT_HASH';
const RESOLVED_BUILD_PATTERN = /^\d{2}\d{3}\d{4}\+[A-Za-z0-9_-]+$/;
const MODES = new Set(['--set', '--ensure', '--check', '--place']);
function readJson(location) {
return JSON.parse(fs.readFileSync(location, 'utf8'));
}
function writeJson(location, value) {
fs.writeFileSync(location, `${JSON.stringify(value, null, 4)}\n`);
}
function getSelectedMode() {
const selectedModes = process.argv.slice(2).filter((value) => MODES.has(value));
if (selectedModes.length !== 1) {
throw new Error('Use exactly one mode: --set, --ensure, --check or --place.');
}
return selectedModes[0];
}
function getVersionParts(version) {
const parts = String(version).split('.');
if (parts.length < 3) {
throw new Error(`Version "${version}" does not follow the expected major.minor.build format.`);
}
return parts;
}
function getVersionPrefix(version) {
const [major, minor] = getVersionParts(version);
return `${major}.${minor}.`;
}
function getBuildPart(version) {
const [, , buildPart] = getVersionParts(version);
return buildPart;
}
function hasPlaceholderVersion(version) {
return version === VERSION_PLACEHOLDER;
}
function hasResolvedVersion(version) {
const buildPart = getBuildPart(version);
return buildPart !== VERSION_PLACEHOLDER && RESOLVED_BUILD_PATTERN.test(buildPart);
}
function getExplicitVersion() {
return process.env.BUILD_VERSION?.trim() || '';
}
function getGitHash() {
const explicitHash = process.env.BUILD_GIT_HASH?.trim() || process.env.GITHUB_SHA?.trim();
if (explicitHash) {
return explicitHash.slice(0, 7);
}
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return 'nogit';
}
}
function getBuildNumber() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const startOfYear = new Date(now.getFullYear(), 0, 1);
const dayOfYear = Math.floor((now - startOfYear) / (1000 * 60 * 60 * 24)) + 1;
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
return `${year}${String(dayOfYear).padStart(3, '0')}${hours}${minutes}`;
}
function resolveVersion(currentVersion) {
const explicitVersion = getExplicitVersion();
if (explicitVersion) {
return explicitVersion;
}
return `${getVersionPrefix(currentVersion)}${getBuildNumber()}-${getGitHash()}`;
}
function isAlreadyEnsured(currentVersion) {
const explicitVersion = getExplicitVersion();
if (explicitVersion) {
return currentVersion === explicitVersion;
}
return hasResolvedVersion(currentVersion);
}
function writeResolvedVersion(resolvedVersion) {
const packageJson = readJson(PACKAGE_JSON_PATH);
packageJson.version = resolvedVersion;
writeJson(PACKAGE_JSON_PATH, packageJson);
if (!fs.existsSync(PACKAGE_LOCK_PATH)) {
return;
}
const packageLock = readJson(PACKAGE_LOCK_PATH);
packageLock.version = resolvedVersion;
if (packageLock.packages && packageLock.packages['']) {
packageLock.packages[''].version = resolvedVersion;
}
writeJson(PACKAGE_LOCK_PATH, packageLock);
}
function runCheck(currentVersion) {
if (hasPlaceholderVersion(currentVersion)) {
console.log(`Placeholder version detected: ${currentVersion}`);
return;
}
throw new Error(
`Resolved build version detected: ${currentVersion}\nShould be: ${VERSION_PLACEHOLDER}`,
);
}
function runEnsure(currentVersion) {
if (isAlreadyEnsured(currentVersion)) {
console.log(`Version already set: ${currentVersion}`);
return;
}
if (hasPlaceholderVersion(currentVersion)) {
throw new Error(`Placeholder version detected: ${currentVersion}`);
}
if (getExplicitVersion()) {
throw new Error(
`Version "${currentVersion}" does not match BUILD_VERSION "${getExplicitVersion()}".`,
);
}
throw new Error(`Version "${currentVersion}" is not a resolved build version.`);
}
function runSet(currentVersion) {
const resolvedVersion = resolveVersion(currentVersion);
writeResolvedVersion(resolvedVersion);
console.log(`Version set to: ${resolvedVersion}`);
}
function runSetPlace() {
writeResolvedVersion(VERSION_PLACEHOLDER);
console.log(`Version set to: ${VERSION_PLACEHOLDER}`);
}
try {
const selectedMode = getSelectedMode();
const packageJson = readJson(PACKAGE_JSON_PATH);
const packageVersion = String(packageJson.version);
const currentVersion = String(packageJson.currentVersion);
switch (selectedMode) {
case '--check':
runCheck(packageVersion);
break;
case '--ensure':
runEnsure(packageVersion);
break;
case '--set':
runSet(currentVersion);
break;
case '--place':
runSetPlace();
break;
default:
throw new Error(`Unknown mode: ${selectedMode}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+61 -1
View File
@@ -1 +1,61 @@
<router-outlet></router-outlet>
<div class="app-content">
<router-outlet></router-outlet>
</div>
<button
type="button"
class="language-switcher"
[attr.aria-label]="content().app.changeLanguageAriaLabel"
[title]="content().app.changeLanguageAriaLabel"
(click)="reopenLanguageSelector()"
>
<span class="language-switcher-tag">{{ currentLanguageCode().toUpperCase() }}</span>
<span class="language-switcher-copy">
<strong>{{ content().app.changeLanguage }}</strong>
<span>{{ languageSwitcherLabel() }}</span>
</span>
</button>
<dialog
#languageDialog
class="language-overlay"
aria-labelledby="language-title"
(close)="closeLanguageSelector()"
(click)="closeLanguageSelectorOnBackdrop($event)"
>
@if (isLanguageSelectorOpen()) {
<section class="language-card panel">
<div class="language-card-header">
<div class="language-copy">
<h2 id="language-title">{{ dialogContent().app.selectorTitle }}</h2>
</div>
<button
type="button"
class="language-close"
[attr.aria-label]="dialogContent().app.closeSelector"
(click)="closeLanguageSelector()"
>
&times;
</button>
</div>
<div class="language-options" role="radiogroup" [attr.aria-label]="dialogContent().app.selectorTitle">
@for (option of languageOptions; track option.code) {
<button
type="button"
class="language-option"
role="radio"
[attr.aria-checked]="selectedLanguage() === option.code"
[attr.autofocus]="selectedLanguage() === option.code ? '' : null"
[class.language-option-active]="selectedLanguage() === option.code"
(click)="chooseLanguage(option.code)"
>
<span class="language-option-accent">{{ option.accent }}</span>
<strong>{{ getLanguageLabel(option.code) }}</strong>
</button>
}
</div>
</section>
}
</dialog>
+95 -19
View File
@@ -1,30 +1,106 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import {AppComponent} from './app.component';
import { AppComponent } from './app.component';
describe('AppComponent', (): void => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
beforeEach(async (): Promise<void> => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', (): void => {
const fixture: ComponentFixture<AppComponent> = TestBed.createComponent(AppComponent);
const app: AppComponent = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'Angular-Portfolio-Seite' title`, (): void => {
const fixture: ComponentFixture<AppComponent> = TestBed.createComponent(AppComponent);
const app: AppComponent = fixture.componentInstance;
expect(app.title).toEqual('Angular-Portfolio-Seite');
});
it('should render title', (): void => {
const fixture: ComponentFixture<AppComponent> = TestBed.createComponent(AppComponent);
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Angular-Portfolio-Seite');
});
it('should create', (): void => {
expect(component).toBeTruthy();
});
it('should keep the language selector closed on first visit', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector<HTMLDialogElement>('dialog.language-overlay')?.open).toBe(false);
});
it('should preselect English by default', (): void => {
const comp = component as unknown as {
selectedLanguage(): string;
};
expect(comp.selectedLanguage()).toBe('en');
});
it('should render the language switcher on first visit', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.language-switcher')).not.toBeNull();
});
it('should always render a router-outlet element', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('router-outlet')).not.toBeNull();
});
it('should confirm and persist the selected language', (): void => {
const comp = component as unknown as {
chooseLanguage(language: 'en' | 'de'): void;
currentLanguageCode(): string;
isLanguageSelectorOpen(): boolean;
};
comp.chooseLanguage('de');
fixture.detectChanges();
expect(comp.currentLanguageCode()).toBe('de');
expect(comp.isLanguageSelectorOpen()).toBe(false);
expect(localStorage.getItem('portfolio-language')).toBe('de');
});
it('should restore a saved language from localStorage', async (): Promise<void> => {
localStorage.setItem('portfolio-language', 'de');
const restoredFixture = TestBed.createComponent(AppComponent);
restoredFixture.detectChanges();
const restoredComponent = restoredFixture.componentInstance as unknown as {
currentLanguageCode(): string;
isLanguageSelectorOpen(): boolean;
};
expect(restoredComponent.currentLanguageCode()).toBe('de');
expect(restoredComponent.isLanguageSelectorOpen()).toBe(false);
});
it('should reopen the selector after confirmation', (): void => {
const comp = component as unknown as {
chooseLanguage(language: 'en' | 'de'): void;
reopenLanguageSelector(): void;
isLanguageSelectorOpen(): boolean;
};
comp.chooseLanguage('en');
comp.reopenLanguageSelector();
fixture.detectChanges();
expect(comp.isLanguageSelectorOpen()).toBe(true);
});
it('should sync the selector state when the dialog closes natively', (): void => {
const comp = component as unknown as {
reopenLanguageSelector(): void;
isLanguageSelectorOpen(): boolean;
};
comp.reopenLanguageSelector();
fixture.detectChanges();
const dialog = (fixture.nativeElement as HTMLElement).querySelector('dialog');
dialog?.dispatchEvent(new Event('close'));
expect(comp.isLanguageSelectorOpen()).toBe(false);
});
});
+80 -8
View File
@@ -1,13 +1,85 @@
import {Component} from '@angular/core'
import {RouterOutlet} from '@angular/router';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
viewChild,
} from '@angular/core';
import type { ElementRef, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import type { LanguageCode } from '../languages/language.types';
import type { LanguageOption } from './app.types';
import { LanguagePreferenceService } from './services/language-preference.service';
import { ShellLanguageService } from './services/shell-language.service';
@Component({
selector: 'app-root',
imports: [
RouterOutlet
],
templateUrl: './app.component.html'
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
title: string = "Angular-Portfolio-Seite";
export class AppComponent implements OnInit {
private readonly languagePreferenceService = inject(LanguagePreferenceService);
private readonly shellLanguageService = inject(ShellLanguageService);
private readonly languageDialog =
viewChild.required<ElementRef<HTMLDialogElement>>('languageDialog');
protected readonly content = this.shellLanguageService.content;
protected readonly currentLanguageCode = this.languagePreferenceService.languageCode;
protected readonly isLanguageSelectorOpen = signal(false);
protected readonly selectedLanguage = signal<LanguageCode>('en');
protected readonly languageOptions: readonly LanguageOption[] = [
{ code: 'en', accent: 'EN' },
{ code: 'de', accent: 'DE' },
];
protected readonly dialogContent = computed(() =>
this.shellLanguageService.getPack(this.selectedLanguage()),
);
protected readonly languageSwitcherLabel = computed(() =>
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
);
private readonly syncDialog = afterRenderEffect((): void => {
const dialog = this.languageDialog().nativeElement;
if (this.isLanguageSelectorOpen() && !dialog.open) {
dialog.showModal();
} else if (!this.isLanguageSelectorOpen() && dialog.open) {
dialog.close();
}
});
ngOnInit(): void {
this.selectedLanguage.set(this.languagePreferenceService.initialize());
}
protected chooseLanguage(language: LanguageCode): void {
this.selectedLanguage.set(language);
this.languagePreferenceService.confirmLanguage(language);
this.isLanguageSelectorOpen.set(false);
}
protected reopenLanguageSelector(): void {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(true);
}
/** Also runs when the dialog closes natively, e.g. via Escape. */
protected closeLanguageSelector(): void {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(false);
}
protected closeLanguageSelectorOnBackdrop(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.closeLanguageSelector();
}
}
protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string {
return language === 'de' ? content.app.languageGerman : content.app.languageEnglish;
}
}
-12
View File
@@ -1,12 +0,0 @@
import {mergeApplicationConfig, ApplicationConfig} from '@angular/core';
import {provideServerRendering} from '@angular/platform-server';
import {appConfig} from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
]
};
export const config: ApplicationConfig = mergeApplicationConfig(appConfig, serverConfig);
+17 -14
View File
@@ -1,19 +1,22 @@
import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core';
import {provideRouter, withInMemoryScrolling} from '@angular/router';
import {BrowserModule, provideClientHydration, withEventReplay} from '@angular/platform-browser';
import type { ApplicationConfig } from '@angular/core';
import { inject, provideAppInitializer, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import {routes} from './app.routes';
import {provideAnimations} from '@angular/platform-browser/animations';
import { routes } from './app.routes';
import { SeoService } from './services/seo.service';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes, withInMemoryScrolling({
anchorScrolling: 'enabled',
scrollPositionRestoration: 'top'
})),
provideClientHydration(withEventReplay()),
importProvidersFrom(BrowserModule),
provideAnimations()
]
provideZonelessChangeDetection(),
provideRouter(
routes,
withInMemoryScrolling({
anchorScrolling: 'enabled',
scrollPositionRestoration: 'top',
}),
),
provideAppInitializer((): void => {
inject(SeoService);
}),
],
};
+13 -8
View File
@@ -1,11 +1,16 @@
import {Routes} from '@angular/router';
import {HomeComponent} from './home/home.component';
import {ImprintComponent} from './imprint/imprint.component';
import type { Routes } from '@angular/router';
export const routes: Routes = [
{path: '', component: HomeComponent, pathMatch: 'full'},
{path: 'imprint', component: ImprintComponent, pathMatch: 'full'},
{path: '**', redirectTo: ''}
{
path: '',
loadComponent: () => import('./home/home.component').then((module) => module.HomeComponent),
pathMatch: 'full',
},
{
path: 'imprint',
loadComponent: () =>
import('./imprint/imprint.component').then((module) => module.ImprintComponent),
pathMatch: 'full',
},
{ path: '**', redirectTo: '' },
];
+6
View File
@@ -0,0 +1,6 @@
import type { LanguageCode } from '../languages/language.types';
export interface LanguageOption {
code: LanguageCode;
accent: string;
}
+8 -14
View File
@@ -1,16 +1,10 @@
<noscript>
<div class="container">
<div class="card">
<h2 id="loading-text" class="warning-text">
Enable javascript for the full experience!
</h2>
</div>
<footer class="site-footer">
<div class="footer-inner">
<p>&copy; 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
@if (showImprintLink()) {
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
} @else {
<a routerLink="/">{{ content().footer.homeLink }}</a>
}
</div>
</noscript>
<footer>
<p>
<a routerLink="/imprint">IMPRINT</a> | &copy; 2023
- {{ currentYear }} {{ globalFields.firstname }} {{ globalFields.lastname }} - All rights reserved.
</p>
</footer>
+95 -6
View File
@@ -1,23 +1,112 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import {FooterComponent} from './footer.component';
import { FooterComponent } from './footer.component';
import { global } from '../../global';
describe('FooterComponent', (): void => {
let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>;
let component: FooterComponent;
interface ComponentAccess {
currentYear: number;
global: typeof global;
}
let comp: ComponentAccess;
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [FooterComponent]
})
.compileComponents();
imports: [FooterComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(FooterComponent);
component = fixture.componentInstance;
comp = component as unknown as ComponentAccess;
fixture.detectChanges();
});
it('should create', (): void => {
expect(component).toBeTruthy();
});
describe('currentYear', (): void => {
it('should equal the current calendar year', (): void => {
expect(comp.currentYear).toBe(new Date().getFullYear());
});
it('should be a four-digit number', (): void => {
expect(comp.currentYear).toBeGreaterThanOrEqual(2024);
expect(comp.currentYear).toBeLessThan(2100);
});
});
describe('global data', (): void => {
it('should reference the shared global object', (): void => {
expect(comp.global).toBe(global);
});
it('should expose a non-empty firstname', (): void => {
expect(comp.global.firstname.length).toBeGreaterThan(0);
});
it('should expose a non-empty lastname', (): void => {
expect(comp.global.lastname.length).toBeGreaterThan(0);
});
});
describe('Template', (): void => {
it('should render the current year in the copyright line', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(String(new Date().getFullYear()));
});
it('should render the author firstname', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.firstname);
});
it('should render the author lastname', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.lastname);
});
it('should render an imprint link with text "Imprint"', (): void => {
const el = fixture.nativeElement as HTMLElement;
const links = Array.from(el.querySelectorAll('a'));
const imprintLink = links.find((a): boolean => a.textContent?.trim() === 'Imprint');
expect(imprintLink).toBeTruthy();
});
it('should link back to the portfolio instead of the imprint on the imprint page', (): void => {
fixture.componentRef.setInput('showImprintLink', false);
fixture.detectChanges();
const links = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('a'));
expect(links.map((a): string => a.textContent?.trim() ?? '')).toEqual(['Portfolio']);
});
it('should contain a footer element', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('footer')).not.toBeNull();
});
});
describe('Performance', (): void => {
it('should create and detect changes within 50 ms', (): void => {
const start = performance.now();
const f = TestBed.createComponent(FooterComponent);
f.detectChanges();
expect(performance.now() - start).toBeLessThan(50);
});
it('should run 20 consecutive change detection cycles within 100 ms', (): void => {
const start = performance.now();
for (let i = 0; i < 20; i++) {
fixture.detectChanges();
}
expect(performance.now() - start).toBeLessThan(100);
});
});
});
+15 -9
View File
@@ -1,16 +1,22 @@
import {Component} from '@angular/core';
import {RouterLink} from "@angular/router";
import {globalFields} from '../../global.fields';
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { global } from '../../global';
import { ShellLanguageService } from '../services/shell-language.service';
@Component({
selector: 'app-footer',
imports: [
RouterLink
],
templateUrl: './footer.component.html'
standalone: true,
imports: [RouterLink],
templateUrl: './footer.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooterComponent {
protected readonly globalFields: typeof globalFields = globalFields;
private readonly shellLanguageService = inject(ShellLanguageService);
protected readonly currentYear: number = new Date().getFullYear();
protected readonly content = this.shellLanguageService.content;
protected readonly global = global;
protected readonly currentYear = new Date().getFullYear();
/** The imprint page links back to the portfolio instead of to itself. */
readonly showImprintLink = input(true);
}
+154 -137
View File
@@ -1,146 +1,163 @@
<section class="container">
<div class="card profile">
<img
ngSrc="Logo.webp"
width="200"
height="200"
priority
fetchpriority="high"
alt="profile picture"
srcset="Logo.webp 1x, Logo.webp 2x"
(click)="openStalkerInfo()"/>
<h1>Hallo! 👋 I'm {{ globalFields.firstname }} {{ globalFields.lastname }}!</h1>
<h3>📍 Austria, Vienna</h3>
<br>
<hr>
<div class="max60">
<div id="short-bio-text" class="hover-items" (click)="toggleBio()">
<fa-icon id="left-arrow" [icon]=" isLongBioShown ? faArrowDown : faArrowRight "></fa-icon>
<a>
<u>
<h3>A young full-stack software developer from Austria.</h3>
</u>
</a>
<fa-icon id="right-arrow" [icon]=" isLongBioShown ? faArrowDown : faArrowLeft "></fa-icon>
<main class="page-shell home-page">
<section class="hero-section panel">
<div class="hero-copy">
<div class="hero-copy-main">
<p id="portfolio-eyebrow" class="eyebrow">{{ content().home.portfolioEyebrow }}</p>
<h1>
<span class="hero-name-part" [attr.data-name]="global.firstname">{{ global.firstname }}</span>
<span class="hero-name-part" [attr.data-name]="global.lastname">{{ global.lastname }}</span>
</h1>
<p class="hero-intro">{{ content().home.heroIntro }}</p>
</div>
</div>
<div class="long-bio-text" *ngIf="isLongBioShown" @fadeInOut (click)="closeOnOverlayClick($event)">
<p>
My name is {{ globalFields.firstname }}, many people also call me Julie. I'm {{ age }} years old and come from
beautiful Austria.
I travel extensively within Austria, so I spend a great deal of my time on trains.
<br/>
<br/>
My biggest hobby apart from software development is scuba diving. Floating in deep water and only thinking
about the here and now always brings me back to the depths. I try to practice this hobby as often as
possible for fun and my health.
<br/>
<br/>
I currently work mainly for the Viennese company SobIT Gmbh. This company develops software for most
care companies in Austria. - A universal management tool for the care sector.
<br/>
<br/>
I also work as a freelancer for various associations, companies, and private clients. The software sector is in
high demand, and I’m fully immersed in planning, programming, and delivering custom solutions upon request.
<br/>
<br/>
<a [href]="contactSafeMail">
<b><u><h3>Just contact me!</h3></u></b>
</a>
</p>
</div>
<hr>
<br>
<div class="bio grid-container">
<div class="grid-item-start">
<h1>I work with</h1>
</div>
<div class="grid-item" *ngFor="let bioText of bioTextsList; let i = index" [hidden]="i !== currentIndex">
<h1>{{ bioText }}</h1>
</div>
</div>
<div class="contact-area">
<a [href]="contactSafeMail">
<fa-icon class="fab" [icon]="faEnvelope" size="3x"></fa-icon>
</a>
<a href="tel:{{globalFields.hrefContactTel}}">
<fa-icon class="fab" [icon]="faPhone" size="3x"></fa-icon>
</a>
<a href="https://discord.com/users/860206216893693973">
<fa-icon class="fab" [icon]="faDiscord" size="3x"></fa-icon>
</a>
<a href="https://github.com/fraujulian">
<fa-icon class="fab" [icon]="faGithub" size="3x"></fa-icon>
</a>
<a href="https://www.xing.com/profile/Julian_Lechner03274">
<fa-icon class="fab" [icon]="faXing" size="3x"></fa-icon>
</a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/">
<fa-icon class="fab" [icon]="faLinkedin" size="3x"></fa-icon>
</a>
</div>
</div>
</section>
<div id="github_projects">
<div *ngFor="let project of projects">
<div class="container" [class.clickable]="project.Clickable" [class.disabled]="!project.Clickable"
[style.cursor]="project.Clickable ? 'pointer' : 'default'"
(click)="project.Clickable && (project.IsReadmeShown = true)">
<div class="card">
<div class="media">
<div class="media-body">
<a href="{{project.Link}}">
<u><strong class="d-block text-gray-dark">{{ project.Title }}</strong></u>
</a>
<div class="stars">
{{ project.Languages.join(' | ') }}
<ng-container *ngIf="project.Stars != null">
<fa-icon [icon]="faStar"></fa-icon>
{{ project.Stars }}
</ng-container>
</div>
<div class="hero-actions">
<div class="hero-action-links">
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
</div>
<div class="hero-contact-block">
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactMailHref" [attr.aria-label]="content().home.mailAriaLabel"
><app-icon [icon]="envelopeIcon" size="lg"></app-icon
></a>
<a href="tel:{{ global.hrefContactPhone }}" [attr.aria-label]="content().home.phoneAriaLabel"
><app-icon [icon]="phoneIcon" size="lg"></app-icon
></a>
<a [href]="global.signalUrl" [attr.aria-label]="content().home.signalAriaLabel"
><app-icon [icon]="signalIcon" size="lg"></app-icon
></a>
<a href="https://git.lechner-systems.at/fraujulian" [attr.aria-label]="content().home.giteaAriaLabel"
><app-icon [icon]="giteaIcon" size="lg"></app-icon
></a>
<a href="https://www.linkedin.com/in/julianlechner2009/" [attr.aria-label]="content().home.linkedinAriaLabel"
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
></a>
</div>
<p>{{ project.Description }}</p>
</div>
</div>
</div>
</div>
</div>
<app-footer ngSkipHydration></app-footer>
<div *ngFor="let project of projects">
<div *ngIf="project.IsReadmeShown" class="modal github-modal">
<div class="modal github-modal card big-card">
<a class="modalClose" (click)="project.IsReadmeShown = false">❌</a>
<a href="{{project.Link}}">Visit on GitHub</a>
<br>
<br>
<div [innerHTML]="project.Readme"></div>
<div class="hero-visual">
@if (currentPortraitHighlight(); as highlight) {
<div class="portrait-highlight">
<button
type="button"
class="portrait-highlight-button"
(click)="showNextPortraitHighlight()"
[attr.aria-label]="content().home.nextHighlightAriaLabel"
[title]="portraitHighlights().length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
>
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching()">
<img
class="hero-feature-image"
[src]="highlight.image"
[attr.srcset]="highlight.imageSrcset"
[attr.sizes]="highlight.imageSizes"
[width]="highlight.imageWidth"
[height]="highlight.imageHeight"
fetchpriority="high"
decoding="async"
[alt]="highlight.text + ' ' + content().home.portraitAltSuffix"
/>
@if (currentPortraitHighlightIndex() === 0) {
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
}
<span class="portrait-caption">{{ highlight.text }}</span>
</div>
</button>
</div>
}
<a
class="hero-meta"
[href]="global.linkedinSkillsUrl"
[attr.aria-label]="content().home.skillsLinkAriaLabel"
(mouseenter)="isBioTickerPaused.set(true)"
(mouseleave)="isBioTickerPaused.set(false)"
>
<div>
<span class="meta-label">{{ content().home.skillsLabel }}</span
><strong>{{ currentBioText() }}</strong>
</div>
</a>
</div>
</div>
</div>
</section>
<div *ngIf="isApiRateLimitExceeded" class="modal github-modal">
<div class="modal github-modal card mini-card">
<a class="modalClose" (click)="isApiRateLimitExceeded = false">❌</a>
<br>
<br>
<h1 style="text-align: center">You have exceeded the today's github api request limits!</h1>
</div>
</div>
<section id="about" class="story-section panel about-scroll-anim-enabled">
<div class="section-heading">
<p class="eyebrow">{{ content().home.aboutEyebrow }}</p>
</div>
<div class="story-grid">
<div class="story-copy">
<p>{{ interpolate(content().home.aboutParagraphs[0]) }}</p>
<p>{{ content().home.aboutParagraphs[1] }}</p>
<p>{{ content().home.aboutParagraphs[2] }}</p>
</div>
<div class="story-detail">
<button class="detail-toggle" type="button" (click)="toggleBio()">
<span>{{ isLongBioShown() ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
><app-icon [icon]="isLongBioShown() ? arrowDownIcon : arrowRightIcon"></app-icon>
</button>
@if (isLongBioMounted()) {
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown()" [class.detail-panel-leave]="!isLongBioShown()">
<p>{{ content().home.detailParagraphs[0] }}</p>
<p>{{ content().home.detailParagraphs[1] }}</p>
<p>{{ content().home.detailParagraphs[2] }}</p>
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">
{{ content().home.getInTouch }}
</button>
</div>
}
</div>
</div>
</section>
<section id="projects" class="projects-section">
<div class="section-heading section-heading-centered">
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
</div>
<div class="project-list">
@for (project of projects(); track project.link + '-' + $index; let i = $index) {
<a
class="panel project-entry"
[class.project-entry-reverse]="i % 2 === 1"
[class.project-entry-from-right]="i % 2 === 0"
[class.project-entry-from-left]="i % 2 === 1"
[href]="project.link"
[attr.aria-label]="content().home.openProjectPrefix + ' ' + project.title"
[attr.data-project-index]="i"
>
@if (project.icon) {
<img
class="project-icon"
[class.project-icon-circle]="project.circleIcon !== false"
[src]="project.icon"
[attr.srcset]="project.iconSrcset ?? null"
[attr.sizes]="project.iconSizes ?? null"
[width]="project.iconWidth ?? 216"
[height]="project.iconHeight ?? 216"
loading="eager"
fetchpriority="low"
decoding="async"
[alt]="project.title + ' ' + content().home.projectIconAltSuffix"
/>
}
<div class="project-copy">
<div class="project-header">
<div class="project-title-wrap">
<span class="project-link"
>{{ project.titleHead
}}<span class="project-title-tail"
>{{ project.titleTail }}<app-icon class="project-arrow" [icon]="arrowRightIcon"></app-icon></span
></span>
</div>
</div>
<p class="project-description">{{ project.description }}</p>
</div>
</a>
}
</div>
</section>
</main>
<app-footer></app-footer>
+547 -6
View File
@@ -1,23 +1,564 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import type { Mock } from 'vitest';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NgZone } from '@angular/core';
import type { Signal, WritableSignal } from '@angular/core';
import {HomeComponent} from './home.component';
import { HomeComponent } from './home.component';
import { global } from '../../global';
import { enLanguage } from '../../languages/en';
import type { LanguagePortraitHighlight } from '../../languages/language.types';
describe('HomeComponent', (): void => {
let component: HomeComponent;
beforeEach((): void => {
// performance stays real so the change detection timing tests keep measuring.
vi.useFakeTimers({
toFake: [
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
'requestAnimationFrame',
'cancelAnimationFrame',
],
});
// jsdom does not implement matchMedia.
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: false }) as MediaQueryList);
});
afterEach((): void => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
interface ComponentAccess {
age: number;
currentIndex: WritableSignal<number>;
isBioTickerPaused: WritableSignal<boolean>;
isLongBioShown: WritableSignal<boolean>;
isLongBioMounted: WritableSignal<boolean>;
isPortraitSwitching: WritableSignal<boolean>;
currentPortraitHighlightIndex: WritableSignal<number>;
portraitHighlights: Signal<LanguagePortraitHighlight[]>;
bioHideTimeoutId: number | null;
scrollEffectsAnimationFrameId: number | null;
scheduleScrollEffectsUpdate(): void;
shouldEnableScrollEffects(): boolean;
toggleBio(): void;
showNextPortraitHighlight(): void;
scrollToAboutSection(): void;
scrollToProjectsSection(): void;
scrollToContactLinks(): void;
currentBioText: Signal<string>;
currentPortraitHighlight: Signal<LanguagePortraitHighlight | null>;
}
let comp: ComponentAccess;
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [HomeComponent]
})
.compileComponents();
imports: [HomeComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
comp = component as unknown as ComponentAccess;
fixture.detectChanges();
});
afterEach((): void => {
// Explicitly destroy to stop the bio ticker interval started after the first render.
// Without this, the interval keeps firing after the test has finished.
fixture.destroy();
});
it('should create', (): void => {
expect(component).toBeTruthy();
});
// ── Age ────────────────────────────────────────────────────────────────────
describe('age', (): void => {
it('should be a positive number', (): void => {
expect(comp.age).toBeGreaterThan(0);
});
it('should match the expected age derived from the birthdate', (): void => {
const birth = new Date(global.birthdate);
const now = new Date();
let expected = now.getFullYear() - birth.getFullYear();
const hasPassed =
now.getMonth() > birth.getMonth() ||
(now.getMonth() === birth.getMonth() && now.getDate() >= birth.getDate());
if (!hasPassed) expected -= 1;
expect(comp.age).toBe(expected);
});
});
// ── Bio cycling ────────────────────────────────────────────────────────────
describe('currentBioText', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex.set(0);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
it('should return the correct entry when index changes', (): void => {
comp.currentIndex.set(3);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]);
});
it('should return an empty text when index is out of range', (): void => {
comp.currentIndex.set(99999);
expect(comp.currentBioText()).toBe('');
});
});
describe('interval-driven bio cycling', (): void => {
it('should advance to the next entry every 1250 ms', async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(1249);
expect(comp.currentIndex()).toBe(0);
await vi.advanceTimersByTimeAsync(1);
expect(comp.currentIndex()).toBe(1);
});
it('should pause while the bio is hovered', async (): Promise<void> => {
const meta = (fixture.nativeElement as HTMLElement).querySelector('.hero-meta');
meta?.dispatchEvent(new MouseEvent('mouseenter'));
await vi.advanceTimersByTimeAsync(2500);
expect(comp.currentIndex()).toBe(0);
meta?.dispatchEvent(new MouseEvent('mouseleave'));
await vi.advanceTimersByTimeAsync(1250);
expect(comp.currentIndex()).toBe(1);
});
it('should not cycle when reduced motion is requested', async (): Promise<void> => {
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: true }) as MediaQueryList);
const reducedFixture = TestBed.createComponent(HomeComponent);
reducedFixture.detectChanges();
const reduced = reducedFixture.componentInstance as unknown as ComponentAccess;
await vi.advanceTimersByTimeAsync(5000);
expect(reduced.currentIndex()).toBe(0);
reducedFixture.destroy();
});
it('should start with currentIndex 0', (): void => {
expect(comp.currentIndex()).toBe(0);
});
it('should expose a valid bio entry for the current index', (): void => {
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
const last = enLanguage.bioTextsList.length - 1;
comp.currentIndex.set(last);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length);
expect(comp.currentIndex()).toBe(0);
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
});
});
// ── Portrait highlight ─────────────────────────────────────────────────────
describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex.set(0);
expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]);
});
it('should return null when index is out of range', (): void => {
comp.currentPortraitHighlightIndex.set(99999);
expect(comp.currentPortraitHighlight()).toBeNull();
});
});
describe('portrait preloading', (): void => {
it('should preload every highlight image except the visible one', (): void => {
const preloaded: string[] = [];
vi.spyOn(window, 'Image').mockImplementation(function (): HTMLImageElement {
const image = document.createElement('img');
Object.defineProperty(image, 'src', {
set: (value: string): void => {
preloaded.push(value);
},
});
return image;
});
const preloadFixture = TestBed.createComponent(HomeComponent);
preloadFixture.detectChanges();
expect(preloaded).toEqual(enLanguage.portraitHighlights.slice(1).map((h) => h.image));
preloadFixture.destroy();
});
});
describe('showNextPortraitHighlight', (): void => {
it('should set isPortraitSwitching to true immediately', (): void => {
comp.isPortraitSwitching.set(false);
comp.showNextPortraitHighlight();
expect(comp.isPortraitSwitching()).toBe(true);
});
it('should not advance index when already switching', (): void => {
comp.isPortraitSwitching.set(true);
comp.showNextPortraitHighlight();
expect(comp.currentPortraitHighlightIndex()).toBe(0);
});
it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = comp.portraitHighlights();
const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry
const setSpy = vi.spyOn(window, 'setTimeout');
comp.showNextPortraitHighlight();
expect(setSpy).not.toHaveBeenCalled();
highlights.splice(0, highlights.length, ...saved); // restore from snapshot
});
it('should advance the portrait index after 125 ms', async (): Promise<void> => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex()).toBe(1);
await vi.advanceTimersByTimeAsync(125);
});
it('should reset isPortraitSwitching to false after 250 ms', async (): Promise<void> => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(250);
expect(comp.isPortraitSwitching()).toBe(false);
});
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1);
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex()).toBe(0);
await vi.advanceTimersByTimeAsync(125);
});
});
// ── Bio toggle ─────────────────────────────────────────────────────────────
describe('toggleBio', (): void => {
it('should mount bio immediately on opening', async (): Promise<void> => {
comp.toggleBio();
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(100);
});
it('should set isLongBioShown to true after the double-rAF delay', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
fixture.detectChanges();
expect(comp.isLongBioShown()).toBe(true);
});
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
// Open first
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
// Close
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioShown()).toBe(false);
await vi.advanceTimersByTimeAsync(300);
});
it('should keep isLongBioMounted true during the 300 ms close animation', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(299);
expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(1);
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(false);
});
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
comp.toggleBio();
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
comp.toggleBio(); // start closing
await vi.advanceTimersByTimeAsync(150); // halfway through 300 ms
comp.toggleBio(); // re-open before unmount
await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges();
expect(comp.isLongBioMounted()).toBe(true);
expect(comp.isLongBioShown()).toBe(true);
await vi.advanceTimersByTimeAsync(500);
});
});
// ── Scroll helpers ─────────────────────────────────────────────────────────
describe('scroll helpers', (): void => {
let scrollSpy: Mock;
beforeEach((): void => {
scrollSpy = vi.spyOn(window, 'scrollTo').mockReturnValue(undefined);
});
it('should call window.scrollTo when scrolling to the about section', (): void => {
const el = document.createElement('section');
el.id = 'about';
document.body.appendChild(el);
comp.scrollToAboutSection();
expect(scrollSpy).toHaveBeenCalled();
document.body.removeChild(el);
});
it('should call window.scrollTo when scrolling to the projects section', (): void => {
const el = document.createElement('section');
el.id = 'projects';
document.body.appendChild(el);
comp.scrollToProjectsSection();
expect(scrollSpy).toHaveBeenCalled();
document.body.removeChild(el);
});
it('should call window.scrollTo when scrolling to contact links', (): void => {
const el = document.createElement('div');
el.id = 'contact-links';
document.body.appendChild(el);
comp.scrollToContactLinks();
expect(scrollSpy).toHaveBeenCalled();
document.body.removeChild(el);
});
it('should not throw when the target element does not exist', (): void => {
expect((): void => comp.scrollToAboutSection()).not.toThrow();
});
it('"About me" button click should trigger a scrollTo call', (): void => {
const aboutEl = document.createElement('section');
aboutEl.id = 'about';
document.body.appendChild(aboutEl);
const el = fixture.nativeElement as HTMLElement;
const btn = Array.from(el.querySelectorAll('button')).find(
(b): boolean => (b.textContent ?? '').trim() === 'About me',
);
btn?.click();
expect(scrollSpy).toHaveBeenCalled();
document.body.removeChild(aboutEl);
});
it('"View projects" button click should trigger a scrollTo call', (): void => {
const projectsEl = document.createElement('section');
projectsEl.id = 'projects';
document.body.appendChild(projectsEl);
const el = fixture.nativeElement as HTMLElement;
const btn = Array.from(el.querySelectorAll('button')).find(
(b): boolean => (b.textContent ?? '').trim() === 'View projects',
);
btn?.click();
expect(scrollSpy).toHaveBeenCalled();
document.body.removeChild(projectsEl);
});
});
// ── Lifecycle cleanup ──────────────────────────────────────────────────────
describe('ngOnDestroy', (): void => {
it('should clear pending portrait switch timers on destroy', async (): Promise<void> => {
comp.showNextPortraitHighlight();
const clearSpy = vi.spyOn(window, 'clearTimeout');
fixture.destroy();
expect(clearSpy).toHaveBeenCalled();
});
it('should not throw when destroyed', async (): Promise<void> => {
expect((): void => fixture.destroy()).not.toThrow();
});
});
// ── Template rendering ─────────────────────────────────────────────────────
describe('Template', (): void => {
it('should render the hero section', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.hero-section')).not.toBeNull();
});
it('should render the author firstname in the h1', (): void => {
const el = fixture.nativeElement as HTMLElement;
const h1 = el.querySelector('h1');
expect(h1?.textContent).toContain(global.firstname);
});
it('should render the author lastname in the h1', (): void => {
const el = fixture.nativeElement as HTMLElement;
const h1 = el.querySelector('h1');
expect(h1?.textContent).toContain(global.lastname);
});
it('should link the skills box to the LinkedIn skills page', (): void => {
const el = fixture.nativeElement as HTMLElement;
const link = el.querySelector<HTMLAnchorElement>('a.hero-meta');
expect(link?.getAttribute('href')).toBe(global.linkedinSkillsUrl);
});
it('should render the skills label above the rotating skill', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.meta-label')).not.toBeNull();
});
it('should render a portrait image when a highlight is active', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.portrait-highlight')).not.toBeNull();
});
it('should render the about section', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('#about')).not.toBeNull();
});
it('should render the projects section', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('#projects')).not.toBeNull();
});
it('should render one project entry per localized project entry', (): void => {
const el = fixture.nativeElement as HTMLElement;
const entries = el.querySelectorAll('.project-entry');
expect(entries.length).toBe(enLanguage.projects.length);
});
it('should keep the arrow on the same line as the last title word', (): void => {
const el = fixture.nativeElement as HTMLElement;
const tails = Array.from(el.querySelectorAll('.project-title-tail'));
expect(tails.map((tail): string => tail.textContent?.trim() ?? '')).toEqual(
enLanguage.projects.map((project): string => project.title.split(' ').at(-1) ?? ''),
);
tails.forEach((tail): void => {
expect(tail.querySelector('.project-arrow')).not.toBeNull();
});
});
it('should render all project titles', (): void => {
const el = fixture.nativeElement as HTMLElement;
enLanguage.projects.forEach((project): void => {
expect(el.textContent).toContain(project.title);
});
});
it('should render contact link anchors in the hero section', (): void => {
const el = fixture.nativeElement as HTMLElement;
const contactArea = el.querySelector('#contact-links');
const links = contactArea?.querySelectorAll('a');
expect(links?.length).toBeGreaterThan(0);
});
});
// ── RAF debouncing (performance) ───────────────────────────────────────────
describe('Performance', (): void => {
it('should keep scroll effects enabled on desktop pointers when motion is allowed', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches:
query !== '(prefers-reduced-motion: reduce)' &&
query !== '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(true);
});
it('should keep scroll effects enabled on coarse touch pointers when motion is allowed', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches: query === '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(true);
});
it('should disable scroll effects when reduced motion is requested', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string): MediaQueryList =>
({
matches: query === '(prefers-reduced-motion: reduce)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBe(false);
});
it('should create and run initial change detection within 200 ms', (): void => {
const start = performance.now();
const f = TestBed.createComponent(HomeComponent);
f.detectChanges();
expect(performance.now() - start).toBeLessThan(200);
f.destroy();
});
it('should run 10 consecutive change detection cycles within 100 ms', (): void => {
const start = performance.now();
for (let i = 0; i < 10; i++) {
fixture.detectChanges();
}
expect(performance.now() - start).toBeLessThan(100);
});
it('should batch all scroll effects into a single requestAnimationFrame', async (): Promise<void> => {
// The RAF registered after the first render is still pending under fake timers.
// Resetting the ID to null makes the schedule function treat the slot as free.
comp.scrollEffectsAnimationFrameId = null;
let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
rafCallCount++;
return rafCallCount; // returns non-null so subsequent calls deduplicate
});
comp.scheduleScrollEffectsUpdate();
comp.scheduleScrollEffectsUpdate();
comp.scheduleScrollEffectsUpdate();
expect(rafCallCount).toBe(1);
});
});
});
+275 -225
View File
@@ -1,251 +1,301 @@
import type { OnDestroy } from '@angular/core';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
NgZone,
OnDestroy,
OnInit,
computed,
DestroyRef,
inject,
signal,
} from '@angular/core';
import {trigger, transition, style, animate} from '@angular/animations';
import {faArrowRight, faArrowLeft, faArrowDown, faStar, faEnvelope, faPhone} from '@fortawesome/free-solid-svg-icons';
import { filter, interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
import { IconComponent } from '../shared/icon/icon.component';
import {
faDiscord,
faGithub,
faXing,
faLinkedin
} from '@fortawesome/free-brands-svg-icons';
import {NgForOf, NgIf, NgOptimizedImage} from '@angular/common';
import {FaIconComponent, IconDefinition} from '@fortawesome/angular-fontawesome';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {interval, Subscription} from 'rxjs';
import MarkdownIt from 'markdown-it';
import {globalFields} from '../../global.fields';
import {FooterComponent} from '../footer/footer.component';
import {MatDialog} from '@angular/material/dialog';
import {StalkerComponent} from '../stalker/stalker.component';
interface Project {
Link: string;
Title: string;
FullName?: string;
Description: string;
Languages: string[];
DefaultBranch?: string;
Stars?: number;
IsReadmeShown: boolean;
Readme?: string;
Clickable?: boolean;
}
arrowDownIcon,
arrowRightIcon,
envelopeIcon,
giteaIcon,
linkedinIcon,
phoneIcon,
signalIcon,
} from '../shared/icon/icons';
@Component({
selector: 'app-home',
standalone: true,
imports: [
NgOptimizedImage,
FooterComponent,
NgIf,
FaIconComponent,
NgForOf
],
imports: [FooterComponent, IconComponent],
templateUrl: './home.component.html',
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({opacity: 0}),
animate('300ms ease-in', style({opacity: 1}))
]),
transition(':leave', [
animate('300ms ease-out', style({opacity: 0}))
])
])
]
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit, OnDestroy {
protected readonly globalFields: typeof globalFields = globalFields;
export class HomeComponent implements OnDestroy {
private static readonly bioTickerIntervalMs = 1250;
protected readonly age: number | string = this.calculateAge('2009-03-03');
private readonly destroyRef = inject(DestroyRef);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected isLongBioShown: boolean = false;
protected isApiRateLimitExceeded: boolean = false;
protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faStar: IconDefinition = faStar;
protected readonly faEnvelope: IconDefinition = faEnvelope;
protected readonly faPhone: IconDefinition = faPhone;
readonly faDiscord: IconDefinition = faDiscord;
readonly faGithub: IconDefinition = faGithub;
readonly faXing: IconDefinition = faXing;
readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly contactSafeMail: SafeUrl;
protected readonly personalInformation: string = 'You found an easter egg, why are you here!?!\nYou want to know more personal things about me? - Then you will find your information here!:\nI am currently building an independent foundation for my financial freedom. I am establishing my own software company specializing in client-based projects.\nMy motto, “No stress in life,” guides me to avoid unnecessary pressure.\nI identify as pansexual, meaning I am attracted to individuals regardless of their gender. - I am in a committed relationship. (I have been a man since the very beginning of my life. Don\'t be confused about my username, it\'s just a name!)';
readonly bioTextsList: string[] = [
'C#',
'.NET',
'.NET Framework',
'WPF',
'Avalonia',
'NodeJS',
'Angular',
'TypeScript',
'JavaScript',
'Java',
'HTML',
'(S)CSS',
'Github',
'Gitlab',
'Azure DevOps',
'Ubuntu',
'Debian',
'SSMS',
'MariaDB',
'MySQL',
'Grandle'
];
protected currentIndex = 0;
private sub!: Subscription;
protected projects: Project[] = [
{
Link: "https://www.synradio.de/",
Title: "SynRadio",
Description: "🖥️ An internet radio station that is available on various media.",
Languages: ["TypeScript", "JavaScript", "HTML", "CSS"],
Clickable: false,
IsReadmeShown: false
},
{
Link: "https://www.synhost.de/",
Title: "SynHost",
Description: "❓ A support system integrating various platforms and media to offer employees a unified overview.",
Languages: ["TypeScript"],
Clickable: false,
IsReadmeShown: false
}
]
constructor(private _sanitizer: DomSanitizer, private zone: NgZone, private dialog: MatDialog) {
this.contactSafeMail = this._sanitizer.bypassSecurityTrustUrl(`mailto:${globalFields.contactMail}`);
}
ngOnInit(): void {
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
this.currentIndex = (this.currentIndex + 1) % this.bioTextsList.length;
});
});
setTimeout((): void => {
this.zone.run((): void => {
this.fillProjects();
});
}, 1000);
private scrollEffectsAnimationFrameId: number | null = null;
private readonly scheduleScrollEffectsUpdate: () => void = (): void => {
if (this.scrollEffectsAnimationFrameId !== null) return;
this.scrollEffectsAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollEffectsAnimationFrameId = null;
this.updateNameGradientProgress();
this.updateAboutSectionScrollProgress();
this.updateProjectEntryRevealProgress();
});
};
protected readonly global = global;
protected readonly age = this.calculateAge(global.birthdate);
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected readonly arrowRightIcon = arrowRightIcon;
protected readonly arrowDownIcon = arrowDownIcon;
protected readonly envelopeIcon = envelopeIcon;
protected readonly phoneIcon = phoneIcon;
protected readonly signalIcon = signalIcon;
protected readonly giteaIcon = giteaIcon;
protected readonly linkedinIcon = linkedinIcon;
protected readonly isLongBioShown = signal(false);
protected readonly isLongBioMounted = signal(false);
protected readonly currentPortraitHighlightIndex = signal(0);
protected readonly isPortraitSwitching = signal(false);
protected readonly currentIndex = signal(0);
protected readonly isBioTickerPaused = signal(false);
private bioHideTimeoutId: number | null = null;
private portraitSwitchTimeoutId: number | null = null;
private portraitSwitchDoneTimeoutId: number | null = null;
protected readonly portraitHighlights = computed(() => this.content().portraitHighlights);
/** Splits off the last title word so the arrow stays glued to it and never wraps alone. */
protected readonly projects = computed(() =>
this.content().projects.map((project) => {
const tailStart = project.title.lastIndexOf(' ') + 1;
return {
...project,
titleHead: project.title.slice(0, tailStart),
titleTail: project.title.slice(tailStart),
};
}),
);
protected readonly currentPortraitHighlight = computed(
() => this.portraitHighlights()[this.currentPortraitHighlightIndex()] ?? null,
);
protected readonly currentBioText = computed(
(): string => this.content().bioTextsList[this.currentIndex()] ?? '',
);
private readonly startBrowserEffects = afterNextRender((): void => {
this.preloadPortraitHighlights();
if (!this.shouldEnableScrollEffects()) return;
this.startBioTicker();
window.addEventListener('scroll', this.scheduleScrollEffectsUpdate, { passive: true });
window.addEventListener('resize', this.scheduleScrollEffectsUpdate, { passive: true });
this.scheduleScrollEffectsUpdate();
});
protected interpolate(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{age}}', String(this.age));
}
ngOnDestroy(): void {
this.sub.unsubscribe();
}
openStalkerInfo(): void {
this.dialog.open(StalkerComponent, {
width: '420px',
data: {message: this.personalInformation}
});
}
private fillProjects(): void {
const sortOnKey: <T>(key: keyof T, descending: boolean) => (a: T, b: T) => number = <T>(key: keyof T, descending: boolean): (a: T, b: T) => number => {
return (a: T, b: T): number => {
const aValue = a[key] as number;
const bValue = b[key] as number;
return descending ? bValue - aValue : aValue - bValue;
};
};
fetch(`https://api.github.com/users/${this.globalFields.githubUsername}/repos?per_page=${this.globalFields.numberOfLoadedRepositories}`)
.then(async (rawResponse: Response): Promise<any> => {
if (rawResponse.status === 403) {
const data = await rawResponse.json();
if (data.message && data.documentation_url) {
this.isApiRateLimitExceeded = true;
}
return new Error('403 Forbidden');
}
return rawResponse.json();
})
.then((allProjects: any): void => {
allProjects = allProjects.sort(sortOnKey("stargazers_count", true));
allProjects.sort(sortOnKey("stargazers_count", true)).forEach((currentProject: any): void => {
if (currentProject === undefined || currentProject.fork === true) return;
if (currentProject.description == null) currentProject.description = "";
if (currentProject.name === "FrauJulian") return;
let newProject: Project = {
Link: "",
Title: "",
FullName: "",
Description: "",
Languages: [],
DefaultBranch: "",
Stars: 0,
Clickable: true,
IsReadmeShown: false,
Readme: ""
};
if (currentProject.language != null) {
fetch(currentProject.languages_url)
.then((rawResponse: Response): Promise<any> => rawResponse.json())
.then((languages: any): void => {
newProject.Languages = Object.keys(languages);
});
} else {
newProject.Languages = [""];
}
fetch(`https://raw.githubusercontent.com/${currentProject.full_name}/${currentProject.default_branch}/README.md`)
.then((rawResponse: Response): Promise<any> => rawResponse.text())
.then((readmeText: string): void => {
newProject.Readme = new MarkdownIt({html: true, linkify: true, typographer: true}).render(readmeText);
});
currentProject.name = currentProject.name
.replaceAll("_", " ")
.replaceAll("-", " ");
newProject.Link = currentProject.html_url;
newProject.Title = currentProject.name;
newProject.FullName = currentProject.full_name;
newProject.Description = currentProject.description;
newProject.DefaultBranch = currentProject.default_branch;
newProject.Stars = currentProject.stargazers_count;
newProject.IsReadmeShown = false;
this.projects.push(newProject);
});
});
if (typeof window === 'undefined') return;
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.clearPortraitSwitchTimeouts();
this.destroyScrollEffects();
}
protected toggleBio(): void {
this.isLongBioShown = !this.isLongBioShown;
if (this.isLongBioShown()) {
this.isLongBioShown.set(false);
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = window.setTimeout((): void => {
this.isLongBioMounted.set(false);
this.bioHideTimeoutId = null;
}, 300);
return;
}
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.isLongBioMounted.set(true);
window.requestAnimationFrame((): void => {
window.requestAnimationFrame((): void => {
this.isLongBioShown.set(true);
});
});
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights().length <= 1 || this.isPortraitSwitching()) return;
this.isPortraitSwitching.set(true);
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
this.currentPortraitHighlightIndex.update(
(index: number): number => (index + 1) % this.portraitHighlights().length,
);
this.portraitSwitchTimeoutId = null;
}, 125);
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
this.isPortraitSwitching.set(false);
this.portraitSwitchDoneTimeoutId = null;
}, 250);
}
protected scrollToAboutSection(): void {
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
}
protected scrollToProjectsSection(): void {
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
}
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
protected closeOnOverlayClick(evt: MouseEvent): void {
if ((evt.target as HTMLElement).classList.contains('long-bio-overlay')) {
this.isLongBioShown = false;
private shouldEnableScrollEffects(): boolean {
if (typeof window === 'undefined') return false;
const prefersReducedMotion: boolean = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
return !prefersReducedMotion;
}
/** Loads the other highlight images at low priority so a click swaps them without a visible delay. */
private preloadPortraitHighlights(): void {
const current = this.currentPortraitHighlight();
for (const highlight of this.portraitHighlights()) {
if (highlight === current) continue;
const image = new Image();
image.fetchPriority = 'low';
image.sizes = highlight.imageSizes;
image.srcset = highlight.imageSrcset;
image.src = highlight.image;
}
}
private calculateAge(birthDateString: string): number | string {
let birthDate: Date = new Date(birthDateString);
let now: Date = new Date();
let diffMs: number = now.getTime() - birthDate.getTime();
let diffYears: number = diffMs / (1000 * 60 * 60 * 24 * 365.25);
let tempAge: string = diffYears.toFixed(1);
return tempAge.endsWith('.0') ? Math.round(diffYears) : tempAge;
private startBioTicker(): void {
interval(HomeComponent.bioTickerIntervalMs)
.pipe(
filter((): boolean => !this.isBioTickerPaused()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((): void => {
const length = this.content().bioTextsList.length;
this.currentIndex.update((index: number): number =>
length > 0 ? (index + 1) % length : 0,
);
});
}
private destroyScrollEffects(): void {
window.removeEventListener('scroll', this.scheduleScrollEffectsUpdate);
window.removeEventListener('resize', this.scheduleScrollEffectsUpdate);
if (this.scrollEffectsAnimationFrameId !== null) {
window.cancelAnimationFrame(this.scrollEffectsAnimationFrameId);
this.scrollEffectsAnimationFrameId = null;
}
document.documentElement.style.removeProperty('--name-pride-progress');
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress');
});
}
private clearPortraitSwitchTimeouts(): void {
if (this.portraitSwitchTimeoutId !== null) {
window.clearTimeout(this.portraitSwitchTimeoutId);
this.portraitSwitchTimeoutId = null;
}
if (this.portraitSwitchDoneTimeoutId !== null) {
window.clearTimeout(this.portraitSwitchDoneTimeoutId);
this.portraitSwitchDoneTimeoutId = null;
}
}
private updateNameGradientProgress(): void {
const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number =
portfolioEyebrow === null
? Number.POSITIVE_INFINITY
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
const isMobileViewport: boolean = window.innerWidth <= 700;
const mobileRevealDistance: number = window.innerHeight / 15;
const triggerDistance: number = isMobileViewport
? mobileRevealDistance
: Math.max(portfolioTriggerY, 1);
const rawProgress: number = Math.min(window.scrollY / triggerDistance, 1);
const acceleratedProgress: number = Math.pow(rawProgress, isMobileViewport ? 0.48 : 0.66);
const progress: number =
window.scrollY > 0
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
: acceleratedProgress;
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
}
private updateAboutSectionScrollProgress(): void {
const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) return;
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.96;
const endY: number = viewportHeight * 0.46;
const totalDistance: number = Math.max(startY - endY, 1);
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
}
private updateProjectEntryRevealProgress(): void {
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.98;
const endY: number = viewportHeight * 0.56;
const totalDistance: number = Math.max(startY - endY, 1);
const entriesWithTop: Array<{ entry: HTMLElement; top: number }> = Array.from(
projectEntries,
(entry: HTMLElement) => ({
entry,
top: entry.getBoundingClientRect().top,
}),
);
entriesWithTop.forEach(({ entry, top }): void => {
const rawProgress: number = (startY - top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
});
}
private calculateAge(birthDateString: string): number {
const birthDate: Date = new Date(birthDateString);
const now: Date = new Date();
let age: number = now.getFullYear() - birthDate.getFullYear();
const hasBirthdayPassedThisYear: boolean =
now.getMonth() > birthDate.getMonth() ||
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
if (!hasBirthdayPassedThisYear) age -= 1;
return age;
}
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
if (typeof document === 'undefined' || typeof window === 'undefined') return;
const element: HTMLElement | null = document.getElementById(elementId);
if (element === null) return;
const targetTop: number = Math.max(
element.getBoundingClientRect().top + window.scrollY - offsetPx,
0,
);
window.scrollTo({ top: targetTop, behavior: 'smooth' });
}
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
if (typeof window === 'undefined') return 0;
const minOffsetPx: number = 20;
const maxOffsetPx: number = 48;
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;
return Math.min(Math.max(responsiveOffsetPx, minOffsetPx), maxOffsetPx);
}
}
+82 -73
View File
@@ -1,76 +1,85 @@
<div class="container">
<div class="card profile">
<h1>Imprint</h1>
<h2>Information according to §5 ECG</h2>
{{ globalFields.firstname }} {{ globalFields.lastname }} <br>
{{ street }} {{ houseNumber }} <br>
{{ zip }} {{ city }} <br>
{{ country }} <br>
<br>
<h4>Contact:</h4>
Phone: <b><u><a href="tel:{{globalFields.hrefContactTel}}">{{ globalFields.contactPhone }}</a></u></b> <br>
E-Mail: <b><u><a [href]="contactSafeMail">{{ globalFields.contactMail }}</a></u></b>
<br>
<h4>Abuse Contact:</h4>
E-Mail: <b><u><a [href]="abuseSafeMail">{{ abuseMail }}</a></u></b>
<br>
<h2>Disclaimer:</h2>
<h3>Liability for links</h3>
Our website contains links to external third-party websites over whose content we have no influence. Therefore, we
cannot accept any liability for this third-party content. The respective provider or operator of the pages is
always responsible for the content of the linked pages. The linked pages were checked for possible legal
violations at the time of linking. Illegal content was not recognizable at the time of linking. However, permanent
monitoring of the content of the linked pages is not reasonable without concrete evidence of an infringement. If
we become aware of any legal infringements, we will remove such links immediately.
<br>
<br>
<h3>Copyright</h3>
The content and works created by the site operators on these pages are subject to German copyright law.
Duplication, processing, distribution and any form of commercialization of such material beyond the scope of the
copyright law shall require the prior written consent of its respective author or creator. Downloads and copies of
this site are only permitted for private, non-commercial use. Insofar as the content on this site was not created
by the operator, the copyrights of third parties are respected. In particular, third-party content is identified
as such. Should you nevertheless become aware of a copyright infringement, please inform us accordingly. If we
become aware of any infringements, we will remove such content immediately.
<br>
<br>
<h3>Data privacy</h3>
The use of our website is generally possible without providing personal data. Insofar as personal data (e.g. name,
address or e-mail addresses) is collected on our website, this is always done on a voluntary basis as far as
possible. This data will not be passed on to third parties without your express consent.
We would like to point out that data transmission over the Internet (e.g. when communicating by e-mail) may be
subject to security vulnerabilities. Complete protection of data against access by third parties is not possible.
We hereby expressly prohibit the use of contact data published within the scope of the imprint obligation by third
parties for sending unsolicited advertising and information material. The operators of the website expressly
reserve the right to take legal action in the event of the unsolicited sending of advertising information, such as
spam e-mails.
<div class="max60">
<div id="short-desc" class="hover-items" routerLink="">
<fa-icon [icon]="faArrowRight"></fa-icon>
<a>
<u>
<h2>Back</h2>
</u>
</a>
<fa-icon [icon]="faArrowLeft"></fa-icon>
<main class="page-shell imprint-page">
<section class="hero-section panel legal-hero">
<div class="hero-copy">
<div class="hero-copy-main">
<p class="eyebrow">{{ content().imprint.legalEyebrow }}</p>
<h1>{{ content().imprint.title }}</h1>
<p class="hero-intro legal-intro">{{ bind(content().imprint.intro) }}</p>
</div>
<div class="hero-actions">
<a class="back-link" routerLink=""
><app-icon [icon]="arrowLeftIcon"></app-icon><span>{{ content().imprint.backToPortfolio }}</span></a
>
</div>
</div>
</div>
</div>
<app-footer ngSkipHydration></app-footer>
<div class="hero-visual">
<div class="portrait-highlight legal-contact-box">
<p class="hero-contact-title">{{ content().imprint.provider }}</p>
<p class="legal-provider-name">{{ global.firstname }} {{ global.lastname }}</p>
<p>{{ global.address.street }} {{ global.address.houseNumber }}</p>
<p>{{ global.address.zip }} {{ global.address.city }}</p>
<p>{{ global.address.country }}</p>
<hr />
<p class="hero-contact-title">{{ content().imprint.directContact }}</p>
<p>
{{ content().imprint.phone }}: <a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a>
</p>
<p>
{{ content().imprint.contact }}: <a class="inline-link" [href]="contactMailHref">{{ global.contactMail }}</a>
</p>
<p>
{{ content().imprint.abuse }}: <a class="inline-link" [href]="abuseMailHref">{{ global.abuseMail }}</a>
</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">{{ content().imprint.privacyEyebrow }}</p>
<h2>{{ content().imprint.privacyTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>{{ content().imprint.privacyLeft }}</p>
</div>
<div class="story-detail">
<p>{{ content().imprint.privacyRight }}</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">{{ content().imprint.rightsEyebrow }}</p>
<h2>{{ content().imprint.rightsTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>{{ content().imprint.rightsLeft }}</p>
</div>
<div class="story-detail">
<p>{{ content().imprint.rightsRight }}</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">{{ content().imprint.copyrightEyebrow }}</p>
<h2>{{ content().imprint.copyrightTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>{{ bind(content().imprint.copyrightLeftParagraphs[0]) }}</p>
<p>{{ content().imprint.copyrightLeftParagraphs[1] }}</p>
</div>
<div class="story-detail">
<p>{{ content().imprint.copyrightRightParagraphs[0] }}</p>
<p>{{ content().imprint.copyrightRightParagraphs[1] }}</p>
</div>
</div>
</section>
</main>
<app-footer [showImprintLink]="false"></app-footer>
+145 -5
View File
@@ -1,16 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ImprintComponent } from './imprint.component';
import { global } from '../../global';
describe('ImprintComponent', (): void => {
let component: ImprintComponent;
let fixture: ComponentFixture<ImprintComponent>;
let component: ImprintComponent;
interface BindAccess {
bind(value: string): string;
}
const bind = (value: string): string => (component as unknown as BindAccess).bind(value);
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [ImprintComponent]
})
.compileComponents();
imports: [ImprintComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(ImprintComponent);
component = fixture.componentInstance;
@@ -20,4 +29,135 @@ describe('ImprintComponent', (): void => {
it('should create', (): void => {
expect(component).toBeTruthy();
});
describe('mail links', (): void => {
it('should render the contact email as a mailto link', (): void => {
const el = fixture.nativeElement as HTMLElement;
const link = el.querySelector(`a[href="mailto:${global.contactMail}"]`);
expect(link).not.toBeNull();
expect(link?.textContent).toContain(global.contactMail);
});
it('should render the abuse email as a mailto link', (): void => {
const el = fixture.nativeElement as HTMLElement;
const link = el.querySelector(`a[href="mailto:${global.abuseMail}"]`);
expect(link).not.toBeNull();
expect(link?.textContent).toContain(global.abuseMail);
});
});
describe('bind', (): void => {
it('should return a string', (): void => {
expect(typeof bind('test')).toBe('string');
});
it('should leave plain ASCII text unchanged', (): void => {
expect(bind('Hello World')).toBe('Hello World');
});
it('should replace the firstname placeholder', (): void => {
expect(bind('{{firstname}}')).toBe(global.firstname);
});
it('should replace the lastname placeholder', (): void => {
expect(bind('{{lastname}}')).toBe(global.lastname);
});
it('should replace the contact mail placeholder', (): void => {
expect(bind('{{contactMail}}')).toBe(global.contactMail);
});
it('should replace the contact phone placeholder', (): void => {
expect(bind('{{contactPhone}}')).toBe(global.contactPhone);
});
it('should replace multiple placeholders in one value', (): void => {
expect(bind('{{firstname}} {{lastname}}')).toBe(`${global.firstname} ${global.lastname}`);
});
it('should handle an empty string', (): void => {
expect(bind('')).toBe('');
});
});
describe('Address display', (): void => {
it('should render the street and house number', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(`${global.address.street} ${global.address.houseNumber}`);
});
it('should render the zip and city', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(`${global.address.zip} ${global.address.city}`);
});
it('should not leave unresolved placeholders in the rendered page', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).not.toContain('{{');
});
});
describe('Template', (): void => {
it('should render the author firstname', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.firstname);
});
it('should render the author lastname', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.lastname);
});
it('should render the country in the address block', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.address.country);
});
it('should render the contact email as visible text', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.contactMail);
});
it('should render the abuse email as visible text', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.abuseMail);
});
it('should render the phone number', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain(global.contactPhone);
});
it('should render an "Imprint & Privacy" heading', (): void => {
const el = fixture.nativeElement as HTMLElement;
const headings = Array.from(el.querySelectorAll('h1'));
expect(headings.some((h): boolean => h.textContent?.includes('Imprint') === true)).toBe(true);
});
it('should contain a back link to the portfolio root', (): void => {
const el = fixture.nativeElement as HTMLElement;
const backLink = el.querySelector('a.back-link');
expect(backLink).not.toBeNull();
});
});
describe('Performance', (): void => {
it('should create, initialize and run change detection within 100 ms', (): void => {
const start = performance.now();
const f = TestBed.createComponent(ImprintComponent);
f.detectChanges();
expect(performance.now() - start).toBeLessThan(100);
});
it('should complete 500 bind calls within 20 ms', (): void => {
const input = '{{firstname}} {{lastname}} {{contactMail}} {{contactPhone}}';
const start = performance.now();
for (let i = 0; i < 500; i++) {
bind(input);
}
expect(performance.now() - start).toBeLessThan(20);
});
});
});
+25 -33
View File
@@ -1,41 +1,33 @@
import {Component, OnInit} from '@angular/core';
import {FooterComponent} from '../footer/footer.component';
import {RouterLink} from '@angular/router';
import {globalFields} from '../../global.fields';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {FaIconComponent, IconDefinition} from '@fortawesome/angular-fontawesome';
import {faArrowDown, faArrowLeft, faArrowRight} from '@fortawesome/free-solid-svg-icons';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
import { IconComponent } from '../shared/icon/icon.component';
import { arrowLeftIcon } from '../shared/icon/icons';
@Component({
selector: 'app-imprint',
imports: [
FooterComponent,
FaIconComponent,
RouterLink
],
templateUrl: './imprint.component.html'
standalone: true,
imports: [FooterComponent, IconComponent, RouterLink],
templateUrl: './imprint.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImprintComponent implements OnInit {
protected readonly globalFields: typeof globalFields = globalFields;
export class ImprintComponent {
private readonly languageService = inject(LanguageService);
protected readonly street: string = 'Ulmenstraße';
protected readonly houseNumber: number = 9;
protected readonly zip: number = 3380;
protected readonly city: string = 'Pöchlarn';
protected readonly country: string = 'Austria';
protected readonly abuseMail: string = 'abuse@lechner-systems.at';
protected readonly content = this.languageService.content;
protected readonly global = global;
protected readonly arrowLeftIcon = arrowLeftIcon;
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected readonly abuseMailHref = `mailto:${global.abuseMail}`;
protected contactSafeMail!: SafeUrl;
protected abuseSafeMail!: SafeUrl;
protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
constructor(private _sanitizer: DomSanitizer) {
}
ngOnInit(): void {
this.contactSafeMail = this._sanitizer.bypassSecurityTrustUrl(`mailto:${globalFields.contactMail}`);
this.abuseSafeMail = this._sanitizer.bypassSecurityTrustUrl(`mailto:${this.abuseMail}`);
protected bind(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{lastname}}', this.global.lastname)
.replaceAll('{{contactMail}}', this.global.contactMail)
.replaceAll('{{contactPhone}}', this.global.contactPhone);
}
}
@@ -0,0 +1,52 @@
import { TestBed } from '@angular/core/testing';
import { LanguagePreferenceService } from './language-preference.service';
describe('LanguagePreferenceService', (): void => {
let service: LanguagePreferenceService;
beforeEach((): void => {
localStorage.clear();
window.history.replaceState(null, '', '/');
service = TestBed.inject(LanguagePreferenceService);
});
afterEach((): void => {
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
it('should fall back to English for unsupported browser languages', (): void => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('fr-FR');
expect(service.initialize()).toBe('en');
});
it('should use the browser language on the first visit', (): void => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT');
expect(service.initialize()).toBe('de');
});
it('should prefer a stored choice over the browser language', (): void => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT');
localStorage.setItem('portfolio-language', 'en');
expect(service.initialize()).toBe('en');
});
it('should prefer the lang query parameter over everything else', (): void => {
localStorage.setItem('portfolio-language', 'en');
window.history.replaceState(null, '', '/?lang=de');
expect(service.initialize()).toBe('de');
});
it('should ignore an unsupported lang query parameter', (): void => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
window.history.replaceState(null, '', '/?lang=fr');
expect(service.initialize()).toBe('en');
});
it('should persist a confirmed language', (): void => {
service.confirmLanguage('de');
expect(service.languageCode()).toBe('de');
expect(localStorage.getItem('portfolio-language')).toBe('de');
});
});
@@ -0,0 +1,39 @@
import { Injectable, signal } from '@angular/core';
import type { LanguageCode } from '../../languages/language.types';
const isLanguageCode = (value: string | null): value is LanguageCode =>
value === 'en' || value === 'de';
@Injectable({ providedIn: 'root' })
export class LanguagePreferenceService {
private static readonly storageKey = 'portfolio-language';
private readonly currentLanguageCode = signal<LanguageCode>('en');
readonly languageCode = this.currentLanguageCode.asReadonly();
/** Picks the language from `?lang=`, then the stored choice, then the browser language. */
initialize(): LanguageCode {
if (typeof window === 'undefined') {
return this.currentLanguageCode();
}
const candidates = [
new URLSearchParams(window.location.search).get('lang'),
window.localStorage.getItem(LanguagePreferenceService.storageKey),
window.navigator.language.slice(0, 2).toLowerCase(),
];
const language = candidates.find(isLanguageCode) ?? 'en';
this.currentLanguageCode.set(language);
return language;
}
confirmLanguage(code: LanguageCode): void {
this.currentLanguageCode.set(code);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LanguagePreferenceService.storageKey, code);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, computed, inject } from '@angular/core';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import type { LanguagePack } from '../../languages/language.types';
import { LanguagePreferenceService } from './language-preference.service';
/** Page content for the lazy-loaded routes; kept apart from the shell texts to stay out of the initial bundle. */
@Injectable({ providedIn: 'root' })
export class LanguageService {
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
readonly content = computed<LanguagePack>(() =>
this.languageCode() === 'de' ? deLanguage : enLanguage,
);
}
+79
View File
@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { Component } from '@angular/core';
import { LanguagePreferenceService } from './language-preference.service';
import { SeoService } from './seo.service';
@Component({ template: '' })
class EmptyComponent {}
describe('SeoService', (): void => {
const getHref = (selector: string): string | null =>
document.head.querySelector(selector)?.getAttribute('href') ?? null;
beforeEach(async (): Promise<void> => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: '', component: EmptyComponent },
{ path: 'imprint', component: EmptyComponent },
]),
],
});
TestBed.inject(SeoService);
await TestBed.inject(Router).navigateByUrl('/');
TestBed.tick();
});
it('should describe the English start page by default', (): void => {
expect(document.documentElement.lang).toBe('en');
expect(document.title).toContain('Julian Lechner');
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/');
expect(getHref('link[hreflang="de"]')).toBe('https://fraujulian.xyz/?lang=de');
expect(getHref('link[hreflang="x-default"]')).toBe('https://fraujulian.xyz/');
});
it('should switch language, title and canonical URL together', async (): Promise<void> => {
TestBed.inject(LanguagePreferenceService).confirmLanguage('de');
await TestBed.inject(Router).navigateByUrl('/imprint');
TestBed.tick();
expect(document.documentElement.lang).toBe('de');
expect(document.title).toContain('Impressum');
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/imprint?lang=de');
expect(document.head.querySelector('meta[property="og:locale"]')?.getAttribute('content')).toBe(
'de_AT',
);
});
describe('home title', (): void => {
beforeEach(async (): Promise<void> => {
TestBed.resetTestingModule();
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [provideRouter([{ path: '', component: EmptyComponent }])],
});
TestBed.inject(SeoService);
await TestBed.inject(Router).navigateByUrl('/');
TestBed.tick();
});
afterEach((): void => {
vi.useRealTimers();
});
it('should alternate between name and role every 3.5 seconds', (): void => {
expect(document.title).toBe('Julian Lechner');
vi.advanceTimersByTime(3500);
TestBed.tick();
expect(document.title).toBe('Full Stack Developer');
vi.advanceTimersByTime(3500);
TestBed.tick();
expect(document.title).toBe('Julian Lechner');
});
});
});
+89
View File
@@ -0,0 +1,89 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Meta, Title } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter, interval, map } from 'rxjs';
import { global } from '../../global';
import type { LanguageCode } from '../../languages/language.types';
import { LanguagePreferenceService } from './language-preference.service';
import { ShellLanguageService } from './shell-language.service';
const languageCodes: readonly LanguageCode[] = ['en', 'de'];
const ogLocales: Readonly<Record<LanguageCode, string>> = { en: 'en_US', de: 'de_AT' };
const homeTitles: readonly string[] = ['Julian Lechner', 'Full Stack Developer'];
const homeTitleSwitchMs = 3500;
/** Keeps `<html lang>`, title, description, canonical, hreflang and Open Graph tags in sync. */
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly router = inject(Router);
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
private readonly content = inject(ShellLanguageService).content;
private readonly path = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event: NavigationEnd): string => event.urlAfterRedirects.split(/[?#]/u)[0] ?? '/'),
),
{ initialValue: '/' },
);
private readonly titleTick = toSignal(interval(homeTitleSwitchMs), { initialValue: -1 });
private readonly syncHead = effect((): void => {
this.update(this.languageCode(), this.path());
});
/** The start page title alternates in the browser tab; other pages keep a fixed title. */
private readonly syncTitle = effect((): void => {
const homeTitle = homeTitles[(this.titleTick() + 1) % homeTitles.length] ?? global.firstname;
this.title.setTitle(this.path() === '/imprint' ? this.content().meta.imprintTitle : homeTitle);
});
private update(language: LanguageCode, path: string): void {
const { meta } = this.content();
const pageTitle = path === '/imprint' ? meta.imprintTitle : meta.homeTitle;
const pageUrl = this.getPageUrl(path, language);
this.document.documentElement.lang = language;
this.meta.updateTag({ name: 'description', content: meta.description });
this.meta.updateTag({ property: 'og:title', content: pageTitle });
this.meta.updateTag({ property: 'og:description', content: meta.description });
this.meta.updateTag({ property: 'og:url', content: pageUrl });
this.meta.updateTag({ property: 'og:locale', content: ogLocales[language] });
this.setLink('link[rel="canonical"]', { rel: 'canonical', href: pageUrl });
for (const code of languageCodes) {
this.setLink(`link[rel="alternate"][hreflang="${code}"]`, {
rel: 'alternate',
hreflang: code,
href: this.getPageUrl(path, code),
});
}
this.setLink('link[rel="alternate"][hreflang="x-default"]', {
rel: 'alternate',
hreflang: 'x-default',
href: this.getPageUrl(path, 'en'),
});
}
/** English is the default and lives on the plain URL; other languages use `?lang=`. */
private getPageUrl(path: string, language: LanguageCode): string {
const pageUrl = `${global.siteUrl}${path}`;
return language === 'en' ? pageUrl : `${pageUrl}?lang=${language}`;
}
private setLink(selector: string, attributes: Readonly<Record<string, string>>): void {
let link = this.document.head.querySelector<HTMLLinkElement>(selector);
if (link === null) {
link = this.document.createElement('link');
this.document.head.appendChild(link);
}
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
}
}
@@ -0,0 +1,16 @@
import { Injectable, computed, inject } from '@angular/core';
import { deShellLanguage, enShellLanguage } from '../../languages/shell';
import type { LanguageCode, LanguageShellPack } from '../../languages/language.types';
import { LanguagePreferenceService } from './language-preference.service';
@Injectable({ providedIn: 'root' })
export class ShellLanguageService {
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
readonly content = computed<LanguageShellPack>(() => this.getPack(this.languageCode()));
getPack(code: LanguageCode): LanguageShellPack {
return code === 'de' ? deShellLanguage : enShellLanguage;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import type { SvgIcon, SvgIconSize } from './icon.types';
@Component({
selector: 'app-icon',
standalone: true,
template: `
<svg
class="app-icon"
[class.app-icon-lg]="size() === 'lg'"
[attr.viewBox]="icon().viewBox"
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="currentColor" [attr.d]="icon().path" />
</svg>
`,
styles: [
`
:host {
display: inline-flex;
line-height: 1;
}
.app-icon {
box-sizing: content-box;
display: inline-block;
width: 1.25em;
height: 1em;
overflow: visible;
vertical-align: -0.125em;
}
.app-icon-lg {
font-size: 1.25em;
vertical-align: -0.2em;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IconComponent {
readonly icon = input.required<SvgIcon>();
readonly size = input<SvgIconSize>('base');
}
+6
View File
@@ -0,0 +1,6 @@
export interface SvgIcon {
readonly viewBox: string;
readonly path: string;
}
export type SvgIconSize = 'base' | 'lg';
+43
View File
@@ -0,0 +1,43 @@
import type { SvgIcon } from './icon.types';
export const arrowDownIcon: SvgIcon = {
viewBox: '0 0 384 512',
path: 'M169.4 502.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 402.7 224 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 370.7-105.4-105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z',
};
export const arrowLeftIcon: SvgIcon = {
viewBox: '0 0 512 512',
path: 'M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z',
};
export const arrowRightIcon: SvgIcon = {
viewBox: '0 0 512 512',
path: 'M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z',
};
// Signal logo from Simple Icons (CC0 1.0).
export const signalIcon: SvgIcon = {
viewBox: '0 0 24 24',
path: 'M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125',
};
export const envelopeIcon: SvgIcon = {
viewBox: '0 0 512 512',
path: 'M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z',
};
// Gitea logo from Simple Icons (CC0 1.0).
export const giteaIcon: SvgIcon = {
viewBox: '0 0 24 24',
path: 'M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z',
};
export const linkedinIcon: SvgIcon = {
viewBox: '0 0 448 512',
path: 'M416 32L31.9 32C14.3 32 0 46.5 0 64.3L0 447.7C0 465.5 14.3 480 31.9 480L416 480c17.6 0 32-14.5 32-32.3l0-383.4C448 46.5 433.6 32 416 32zM135.4 416l-66.4 0 0-213.8 66.5 0 0 213.8-.1 0zM102.2 96a38.5 38.5 0 1 1 0 77 38.5 38.5 0 1 1 0-77zM384.3 416l-66.4 0 0-104c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9l0 105.8-66.4 0 0-213.8 63.7 0 0 29.2 .9 0c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9l0 117.2z',
};
export const phoneIcon: SvgIcon = {
viewBox: '0 0 512 512',
path: 'M160.2 25C152.3 6.1 131.7-3.9 112.1 1.4l-5.5 1.5c-64.6 17.6-119.8 80.2-103.7 156.4 37.1 175 174.8 312.7 349.8 349.8 76.3 16.2 138.8-39.1 156.4-103.7l1.5-5.5c5.4-19.7-4.7-40.3-23.5-48.1l-97.3-40.5c-16.5-6.9-35.6-2.1-47 11.8l-38.6 47.2C233.9 335.4 177.3 277 144.8 205.3L189 169.3c13.9-11.3 18.6-30.4 11.8-47L160.2 25z',
};
-9
View File
@@ -1,9 +0,0 @@
<h1 mat-dialog-title>STALKER!!!</h1>
<div mat-dialog-content *ngFor="let line of lines">
{{ line }} <br>
</div>
<div mat-dialog-actions>
<button mat-button mat-dialog-close>Close</button>
</div>
-23
View File
@@ -1,23 +0,0 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {StalkerComponent} from './stalker.component';
describe('StalkerComponent', (): void => {
let component: StalkerComponent;
let fixture: ComponentFixture<StalkerComponent>;
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [StalkerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(StalkerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', (): void => {
expect(component).toBeTruthy();
});
});
-30
View File
@@ -1,30 +0,0 @@
import {Component, Inject} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import {MatButton} from '@angular/material/button';
import {NgForOf, CommonModule} from '@angular/common';
@Component({
selector: 'app-stalker',
imports: [
MatDialogTitle,
MatDialogActions,
MatDialogContent,
MatButton,
MatDialogClose,
CommonModule
],
templateUrl: './stalker.component.html'
})
export class StalkerComponent {
lines: string[];
constructor(@Inject(MAT_DIALOG_DATA) public data: { message: string }) {
this.lines = (data.message ?? '').split('\n');
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

-9
View File
@@ -1,9 +0,0 @@
export class globalFields {
public static readonly firstname: string = 'Julian';
public static readonly lastname: string = 'Lechner';
public static readonly contactMail: string = 'office@lechner-systems.at';
public static readonly contactPhone: string = '+43 (0) 660 9254001';
public static readonly hrefContactTel: string = '+436609254001';
public static readonly githubUsername: string = 'FrauJulian';
public static readonly numberOfLoadedRepositories: number = 999;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Global } from './global.types';
export const global: Global = {
firstname: 'Julian',
lastname: 'Lechner',
siteUrl: 'https://fraujulian.xyz',
contactMail: 'julian@lechner.top',
abuseMail: 'abuse@lechner-systems.at',
linkedinSkillsUrl: 'https://www.linkedin.com/in/julianlechner2009/details/skills/',
signalUrl:
'https://signal.me/#eu/4c3PvAZdPSjVHLJ1zJJ6HChUE_T_HTDyEraQMwK6TRNyQSZCs5ZK-KKS00T9na-0',
contactPhone: '+43 660 9254001',
hrefContactPhone: '+436609254001',
birthdate: '2009-03-03',
address: {
street: 'Ulmenstraße',
houseNumber: 9,
zip: 3380,
city: 'Pöchlarn',
country: 'Österreich',
},
};
+21
View File
@@ -0,0 +1,21 @@
export interface ImprintData {
street: string;
houseNumber: number;
zip: number;
city: string;
country: string;
}
export interface Global {
firstname: string;
lastname: string;
siteUrl: string;
contactMail: string;
abuseMail: string;
linkedinSkillsUrl: string;
signalUrl: string;
contactPhone: string;
hrefContactPhone: string;
birthdate: string;
address: ImprintData;
}
+59 -20
View File
@@ -1,24 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<base href="/">
<html lang="en" class="no-js">
<head>
<script>
document.documentElement.classList.remove('no-js');
</script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<meta
name="description"
content="Full-stack developer building reliable, high-performance systems and clean, maintainable code. Focused on practical solutions that actually hold up in production."
/>
<base href="/" />
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Lechner Julian</title>
</head>
<body>
<app-root></app-root>
<link rel="icon" type="image/webp" sizes="128x128" href="assets/optimized/portrait/me-128.webp" />
<link rel="icon" type="image/png" sizes="32x32" href="assets/optimized/icons/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="assets/optimized/icons/apple-touch-icon.png" />
<title>Julian Lechner</title>
<link rel="preload" as="image" fetchpriority="high" href="assets/optimized/background.webp" />
<link
rel="preload"
as="image"
fetchpriority="high"
href="assets/optimized/portrait/me.webp"
imagesrcset="assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-384.webp 384w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w"
imagesizes="(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px"
/>
<script src='https://storage.ko-fi.com/cdn/scripts/overlay-widget.js'></script>
<script>
kofiWidgetOverlay.draw('fraujulian', {
'type': 'floating-chat',
'floating-chat.donateButton.text': 'Support me',
'floating-chat.donateButton.background-color': '#5bc0de',
'floating-chat.donateButton.text-color': '#323842'
});
</script>
</body>
<meta property="og:type" content="profile" />
<meta property="og:site_name" content="Julian Lechner" />
<meta property="og:image" content="https://fraujulian.xyz/assets/optimized/icons/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Portrait of Julian Lechner" />
<meta property="profile:first_name" content="Julian" />
<meta property="profile:last_name" content="Lechner" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Julian Lechner",
"alternateName": "FrauJulian",
"url": "https://fraujulian.xyz/",
"image": "https://fraujulian.xyz/assets/optimized/icons/og-image.jpg",
"jobTitle": "Full-Stack Developer",
"address": { "@type": "PostalAddress", "addressCountry": "AT" },
"sameAs": ["https://git.lechner-systems.at/fraujulian", "https://www.linkedin.com/in/julianlechner2009/"]
}
</script>
</head>
<body>
<noscript>
<div class="page-shell footer-shell">
<div class="panel noscript-panel">
<p class="warning-text">Please enable JavaScript for the full experience. / Bitte aktiviere JavaScript, um alles zu sehen.</p>
</div>
</div>
</noscript>
<app-root></app-root>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
import type { LanguagePack } from './language.types';
export const deLanguage: LanguagePack = {
home: {
portfolioEyebrow: 'Portfolio',
heroIntro:
'Ich entwickle zuverlässige Systeme, übersichtliche Benutzeroberflächen und praktische digitale Lösungen, die sich in realen Kundensystemen bewähren.',
contactTitle: 'Kontaktiere mich',
viewProjects: 'Projekte',
aboutMe: 'Über mich',
nextHighlightAriaLabel: 'Nächstes Bild anzeigen',
nextHighlightTitle: 'Nächstes Bild anzeigen',
singleHighlightTitle: 'Einzelnes Bild',
portraitAltSuffix: 'Bild',
clickHint: 'Klicke hier!',
aboutEyebrow: 'Über mich',
aboutParagraphs: [
'Ich heiße {{firstname}}, viele nennen mich auch Julie. Ich bin {{age}} Jahre alt und komme aus Österreich. Viel von meinem Alltag spielt sich unterwegs ab – meistens im Zug.',
'Digitale Sicherheit und Privatsphäre sind mir sehr wichtig. Ich baue Systeme, die Angriffsflächen minimieren, bewährte Standards nutzen und Datenschutz konsequent berücksichtigen.',
'Ich arbeite mit modernen Web-Technologien an geschäftskritischen Systemen. Mein Fokus liegt auf klaren, stabilen Lösungen, die zuverlässig funktionieren und echten Nutzen schaffen.',
],
detailToggleOpen: 'Mehr lesen',
detailToggleClose: 'Weniger lesen',
detailParagraphs: [
'Mein Fokus liegt auf modernem Code: sauber strukturiert, wartbar und auf Performance ausgelegt.',
'Abseits von Software und Sicherheit ist Tauchen mein größtes Hobby. Der Gedanke an nichts unter Wasser zieht mich immer wieder zurück in die Tiefe der österreichischen Seen.',
'Hauptsächlich arbeite ich für die SobIT GmbH in Wien, zusätzlich zu langfristigen Projekten für Unternehmen, Organisationen und private Kunden.',
],
getInTouch: 'Kontaktiere mich!',
projectsEyebrow: 'Aktuelle Jobs & Projekte',
openProjectPrefix: 'Projekt öffnen',
projectIconAltSuffix: 'Logo',
mailAriaLabel: 'E-Mail senden',
phoneAriaLabel: 'Telefonnummer anrufen',
signalAriaLabel: 'Nachricht über Signal',
giteaAriaLabel: 'Gitea-Profil',
linkedinAriaLabel: 'LinkedIn-Profil',
skillsLabel: 'Kenntnisse',
skillsLinkAriaLabel: 'Alle Kenntnisse auf LinkedIn ansehen',
},
imprint: {
legalEyebrow: 'Rechtliches',
title: 'Impressum & Datenschutz',
intro:
'Rechtliche Informationen nach österreichischem Recht sowie Datenschutzinformationen gemäß DSGVO für alle Websites von {{firstname}} {{lastname}}.',
backToPortfolio: 'Zurück zum Portfolio',
provider: 'Anbieter',
directContact: 'Direkter Kontakt',
phone: 'Telefon',
contact: 'E-Mail',
abuse: 'Abuse E-Mail',
privacyEyebrow: 'Datenschutz',
privacyTitle: 'Keine Datensammlung',
privacyLeft:
'Diese Website sammelt und verarbeitet keine personenbezogenen Daten. Ausgenommen sind nur Informationen, die beim Aufruf einer Website technisch bedingt übermittelt werden, etwa IP-Adresse, Datum und Uhrzeit, aufgerufene Seite, Referrer sowie Browser- und Betriebssysteminformationen (User-Agent).',
privacyRight:
'Diese Daten dienen ausschließlich dem sicheren und stabilen Betrieb der Website (Art. 6 Abs. 1 lit. f DSGVO). Es gibt kein Tracking und keine Cookies. Die gewählte Sprache wird nur lokal in deinem Browser gespeichert.',
rightsEyebrow: 'Rechte',
rightsTitle: 'Deine Rechte nach der DSGVO',
rightsLeft:
'Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch. Anfragen richtest du an die oben genannte E-Mail-Adresse.',
rightsRight:
'Beschwerden richtest du an die Österreichische Datenschutzbehörde, Barichgasse 40-42, 1030 Wien, dsb@dsb.gv.at.',
copyrightEyebrow: 'Urheberrecht',
copyrightTitle: 'Urheberrecht & Haftung',
copyrightLeftParagraphs: [
'Alle Inhalte aller Websites von {{firstname}} {{lastname}}, insbesondere Texte, Bilder, Fotos, Grafiken und Design, sind urheberrechtlich geschützt. Alle Rechte vorbehalten.',
'Jede Vervielfältigung, Bearbeitung, Verbreitung oder sonstige Nutzung ist ohne meine vorherige schriftliche Zustimmung untersagt. Für den Quellcode gilt ausschließlich die im Repository angegebene Lizenz.',
],
copyrightRightParagraphs: [
'Für Inhalte verlinkter externer Websites übernehme ich keine Haftung. Dafür sind ausschließlich deren Betreiber verantwortlich.',
'Es gilt österreichisches Recht, soweit zwingende Verbraucherschutzvorschriften nichts anderes vorsehen.',
],
},
bioTextsList: [
'C#',
'TypeScript',
'JavaScript',
'Java',
'SQL',
'Bash',
'Windows PowerShell',
'.NET Core',
'.NET-Framework',
'ASP.NET',
'Entity Framework (EF) Core',
'Windows Presentation Foundation (WPF)',
'Model View ViewModel (MVVM)',
'Model View Controller (MVC)',
'Webentwicklung',
'AngularJS',
'Node.js',
'Microsoft SQL Server',
'MariaDB',
'Git',
'GitHub',
'GitLab',
'Azure DevOps',
'Continuous Integration und Continuous Delivery (CI/CD)',
'Systemadministration',
'Linux-Server',
'Windows-Server',
'Proxmox',
'E-Mail-Sicherheit & Authentifizierung: SPF, DKIM, DMARC, DNSSEC/DANE & MTA-STS',
'Künstliche Intelligenz (KI)',
'KI-Agenten',
'Anthropic Claude',
'OpenAI Codex',
'Agile',
'Scrum',
'Projektmanagement',
'Projektkommunikation',
],
portraitHighlights: [
{
image: 'assets/optimized/portrait/me.webp',
imageSrcset:
'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-384.webp 384w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 769,
text: 'Ich',
},
{
image: 'assets/optimized/portrait/love.webp',
imageSrcset:
'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-384.webp 384w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 710,
text: 'Liebe',
},
{
image: 'assets/optimized/portrait/scuba.webp',
imageSrcset:
'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-384.webp 384w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 480,
text: 'Tauchen',
},
{
image: 'assets/optimized/portrait/trains.webp',
imageSrcset:
'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-384.webp 384w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 850,
text: 'Züge',
},
{
image: 'assets/optimized/portrait/traveling.webp',
imageSrcset:
'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-384.webp 384w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 480,
text: 'Reisen',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'Ich arbeite hauptsächlich für die SobIT GmbH aus Wien, ein Tochterunternehmen der Wiener Sozialdienste. Wir entwickeln Software für den Gesundheitsbereich: moderne Desktop-, Mobile- und Webanwendungen zur Unterstützung der mobilen Pflege.',
icon: 'assets/optimized/logos/sobit.webp',
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: false,
},
{
title: 'SynHost',
link: 'https://www.synhost.de/',
description:
'Ein Hosting-Dienstleister von GERLACH SYSTEMS, für die ich Schnittstellen zwischen einzelnen Systemen entwickle und technischen Support mache.',
icon: 'assets/optimized/logos/synhost.webp',
iconSrcset:
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
circleIcon: false,
},
{
title: 'SynRadio',
link: 'https://www.synradio.de/',
description:
'Ein Internetradio, auch unter der Verwaltung von GERLACH SYSTEMS, für den ich die technische Umsetzung und Betreuung der digitalen Plattformen übernehme — Website, Bots und Plugins für Sprachchats und Spiele.',
icon: 'assets/optimized/logos/synradio.webp',
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
{
title: 'Discord Audio Stream Library',
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream',
description:
'Eine TypeScript-Library für Discord, die Audiowiedergabe einfacher macht, mit Fokus auf stabile 24/7-Streams ohne Unterbrechungen.',
icon: 'assets/optimized/logos/discord.webp',
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
{
title: 'Portfolio',
link: '#',
description:
'Diese Portfolio-Website, die du gerade anschaust, ist eines meiner aktiv gepflegten Projekte. 😁',
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me-320.webp 320w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
circleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
link: 'https://tauchertreff-mostviertel.at/',
description:
'Ich unterstütze außerdem meinen lokalen Tauchverein, indem ich die digitale Infrastruktur entwickle, aufbaue und auch betreibe — E-Mail, Website, Cloud-Dienste und alles, was dazugehört.',
icon: 'assets/optimized/logos/tauchertreff.webp',
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
],
};
+238
View File
@@ -0,0 +1,238 @@
import type { LanguagePack } from './language.types';
export const enLanguage: LanguagePack = {
home: {
portfolioEyebrow: 'Portfolio',
heroIntro:
'I build reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.',
contactTitle: 'Contact me',
viewProjects: 'View projects',
aboutMe: 'About me',
nextHighlightAriaLabel: 'Show next image',
nextHighlightTitle: 'Show next image',
singleHighlightTitle: 'Single image',
portraitAltSuffix: 'Image',
clickHint: 'Click me!',
aboutEyebrow: 'About me',
aboutParagraphs: [
'My name is {{firstname}}, many people also call me Julie. I am {{age}} and based in Austria. A large part of my day happens on the move, usually on trains.',
'I care deeply about digital security and privacy. I design systems with minimal exposure, sensible defaults, and a strong focus on data protection.',
'I work with modern web technologies and on business-critical systems. I focus on clear, stable solutions that hold up in day-to-day use and deliver value.',
],
detailToggleOpen: 'Read more',
detailToggleClose: 'Read less',
detailParagraphs: [
'What really interests me is modern code: well-structured, maintainable, and built with performance in mind.',
'Outside of software, my main hobby is scuba diving. The thought of complete stillness underwater keeps drawing me back into the depths of Austria’s lakes.',
'I mainly work for SobIT GmbH in Vienna, alongside long term projects for companies, organizations, and private clients.',
],
getInTouch: 'Get in touch!',
projectsEyebrow: 'Current Jobs & Projects',
openProjectPrefix: 'Open project',
projectIconAltSuffix: 'logo',
mailAriaLabel: 'Send an email',
phoneAriaLabel: 'Call phone number',
signalAriaLabel: 'Message on Signal',
giteaAriaLabel: 'Gitea profile',
linkedinAriaLabel: 'LinkedIn profile',
skillsLabel: 'Skills',
skillsLinkAriaLabel: 'View all skills on LinkedIn',
},
imprint: {
legalEyebrow: 'Legal',
title: 'Imprint & Privacy',
intro:
'Legal information under Austrian law and privacy information under the GDPR for all websites operated by {{firstname}} {{lastname}}.',
backToPortfolio: 'Back to portfolio',
provider: 'Provider',
directContact: 'Direct Contact',
phone: 'Phone',
contact: 'Email',
abuse: 'Abuse Email',
privacyEyebrow: 'Privacy',
privacyTitle: 'No Data Collection',
privacyLeft:
'This website does not collect or process personal data. The only exception is information that is technically transmitted when a website is accessed, such as IP address, date and time, requested page, referrer, and browser and operating system information (user agent).',
privacyRight:
'This data is used solely to operate the website securely and reliably (Art. 6(1)(f) GDPR). There is no tracking and there are no cookies. Your chosen language is stored only locally in your browser.',
rightsEyebrow: 'Rights',
rightsTitle: 'Your Rights Under The GDPR',
rightsLeft:
'You have the right of access, rectification, erasure, restriction of processing, data portability, and objection. Send requests to the email address listed above.',
rightsRight:
'Complaints can be filed with the Austrian Data Protection Authority, Barichgasse 40-42, 1030 Vienna, dsb@dsb.gv.at.',
copyrightEyebrow: 'Copyright',
copyrightTitle: 'Copyright & Liability',
copyrightLeftParagraphs: [
'All content on all websites operated by {{firstname}} {{lastname}}, in particular texts, images, photographs, graphics, and design, is protected by copyright. All rights reserved.',
'Any reproduction, modification, distribution, or other use without my prior written consent is prohibited. The source code is governed solely by the license stated in its repository.',
],
copyrightRightParagraphs: [
'I accept no liability for the content of linked external websites. Their operators are solely responsible for it.',
'Austrian law applies, unless mandatory consumer protection provisions provide otherwise.',
],
},
bioTextsList: [
'C#',
'TypeScript',
'JavaScript',
'Java',
'SQL',
'Bash',
'Windows PowerShell',
'.NET Core',
'.NET Framework',
'ASP.NET',
'Entity Framework (EF) Core',
'Windows Presentation Foundation (WPF)',
'Model View ViewModel (MVVM)',
'Model View Controller (MVC)',
'Web Development',
'AngularJS',
'Node.js',
'Microsoft SQL Server',
'MariaDB',
'Git',
'GitHub',
'GitLab',
'Azure DevOps',
'Continuous Integration and Continuous Delivery (CI/CD)',
'System Administration',
'Linux Server',
'Windows Server',
'Proxmox',
'Email Security & Authentication: SPF, DKIM, DMARC, DNSSEC/DANE & MTA-STS',
'Artificial Intelligence (AI)',
'AI Agents',
'Anthropic Claude',
'OpenAI Codex',
'Agile',
'Scrum',
'Project Management',
'Project Communication',
],
portraitHighlights: [
{
image: 'assets/optimized/portrait/me.webp',
imageSrcset:
'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-384.webp 384w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 769,
text: 'ME',
},
{
image: 'assets/optimized/portrait/love.webp',
imageSrcset:
'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-384.webp 384w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 710,
text: 'Love',
},
{
image: 'assets/optimized/portrait/scuba.webp',
imageSrcset:
'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-384.webp 384w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 480,
text: 'Scuba Diving',
},
{
image: 'assets/optimized/portrait/trains.webp',
imageSrcset:
'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-384.webp 384w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 850,
text: 'Trains',
},
{
image: 'assets/optimized/portrait/traveling.webp',
imageSrcset:
'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-384.webp 384w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 480,
text: 'Traveling',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'I primarily work for SobIT GmbH, a Vienna-based subsidiary of Wiener Sozialdienste. We develop software for the healthcare sector, focusing on modern desktop, mobile, and web applications that support mobile care services.',
icon: 'assets/optimized/logos/sobit.webp',
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: false,
},
{
title: 'SynHost',
link: 'https://www.synhost.de/',
description:
'A hosting service provider of GERLACH SYSTEMS, for whom I develop interfaces between individual systems and provide technical support.',
icon: 'assets/optimized/logos/synhost.webp',
iconSrcset:
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
circleIcon: false,
},
{
title: 'SynRadio',
link: 'https://www.synradio.de/',
description:
'An online radio project, also operated by GERLACH SYSTEMS, where I build and maintain the technical side of its digital presence — including the website, bots, and plugins for voice chats and games.',
icon: 'assets/optimized/logos/synradio.webp',
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
{
title: 'Discord Audio Stream Library',
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream',
description:
'A TypeScript library for Discord that simplifies audio playback, with a focus on stable 24/7 streaming without interruptions.',
icon: 'assets/optimized/logos/discord.webp',
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
{
title: 'Portfolio',
link: '',
description:
'This portfolio website you’re currently viewing is one of my actively maintained projects. 😁',
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me-320.webp 320w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
circleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
link: 'https://tauchertreff-mostviertel.at/',
description:
'I also support my local diving club by developing, setting up, and operating its digital infrastructure — including email, the website, cloud services, and everything that comes with it.',
icon: 'assets/optimized/logos/tauchertreff.webp',
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
circleIcon: true,
},
],
};
+99
View File
@@ -0,0 +1,99 @@
export type LanguageCode = 'en' | 'de';
export interface LanguageProject {
title: string;
link: string;
description: string;
icon?: string;
iconSrcset?: string;
iconSizes?: string;
iconWidth?: number;
iconHeight?: number;
circleIcon?: boolean;
}
export interface LanguagePortraitHighlight {
image: string;
imageSrcset: string;
imageSizes: string;
imageWidth: number;
imageHeight: number;
text: string;
}
export interface LanguageShellPack {
app: {
selectorTitle: string;
changeLanguage: string;
changeLanguageAriaLabel: string;
closeSelector: string;
languageEnglish: string;
languageGerman: string;
};
footer: {
imprintLink: string;
homeLink: string;
};
meta: {
description: string;
homeTitle: string;
imprintTitle: string;
};
}
export interface LanguagePack {
home: {
portfolioEyebrow: string;
heroIntro: string;
contactTitle: string;
viewProjects: string;
aboutMe: string;
nextHighlightAriaLabel: string;
nextHighlightTitle: string;
singleHighlightTitle: string;
portraitAltSuffix: string;
clickHint: string;
aboutEyebrow: string;
aboutParagraphs: string[];
detailToggleOpen: string;
detailToggleClose: string;
detailParagraphs: string[];
getInTouch: string;
projectsEyebrow: string;
openProjectPrefix: string;
projectIconAltSuffix: string;
mailAriaLabel: string;
phoneAriaLabel: string;
signalAriaLabel: string;
giteaAriaLabel: string;
linkedinAriaLabel: string;
skillsLabel: string;
skillsLinkAriaLabel: string;
};
imprint: {
legalEyebrow: string;
title: string;
intro: string;
backToPortfolio: string;
provider: string;
directContact: string;
phone: string;
contact: string;
abuse: string;
privacyEyebrow: string;
privacyTitle: string;
privacyLeft: string;
privacyRight: string;
rightsEyebrow: string;
rightsTitle: string;
rightsLeft: string;
rightsRight: string;
copyrightEyebrow: string;
copyrightTitle: string;
copyrightLeftParagraphs: string[];
copyrightRightParagraphs: string[];
};
bioTextsList: string[];
portraitHighlights: LanguagePortraitHighlight[];
projects: LanguageProject[];
}
+43
View File
@@ -0,0 +1,43 @@
import type { LanguageShellPack } from './language.types';
export const enShellLanguage: LanguageShellPack = {
app: {
selectorTitle: 'Select language',
changeLanguage: 'Language',
changeLanguageAriaLabel: 'Change language',
closeSelector: 'Close language selection',
languageEnglish: 'English',
languageGerman: 'German',
},
footer: {
imprintLink: 'Imprint',
homeLink: 'Portfolio',
},
meta: {
description:
'Full-stack developer building reliable, high-performance systems and clean, maintainable code. Focused on practical solutions that actually hold up in production.',
homeTitle: 'Julian Lechner – Full-Stack Developer',
imprintTitle: 'Imprint & Privacy – Julian Lechner',
},
};
export const deShellLanguage: LanguageShellPack = {
app: {
selectorTitle: 'Sprache auswählen',
changeLanguage: 'Sprache',
changeLanguageAriaLabel: 'Sprache ändern',
closeSelector: 'Sprachauswahl schließen',
languageEnglish: 'Englisch',
languageGerman: 'Deutsch',
},
footer: {
imprintLink: 'Impressum',
homeLink: 'Portfolio',
},
meta: {
description:
'Full-Stack-Entwickler für zuverlässige, performante Systeme und sauberen, wartbaren Code. Fokus auf praktische Lösungen, die sich im Produktivbetrieb bewähren.',
homeTitle: 'Julian Lechner – Full-Stack-Entwickler',
imprintTitle: 'Impressum & Datenschutz – Julian Lechner',
},
};
-9
View File
@@ -1,9 +0,0 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {ApplicationRef} from '@angular/core';
import {AppComponent} from './app/app.component';
import {config} from './app/app.config.server';
const bootstrap: () => Promise<ApplicationRef> = (): Promise<ApplicationRef> => bootstrapApplication(AppComponent, config);
export default bootstrap;
+4 -5
View File
@@ -1,7 +1,6 @@
import {bootstrapApplication} from '@angular/platform-browser';
import { bootstrapApplication } from '@angular/platform-browser';
import {appConfig} from './app/app.config';
import {AppComponent} from './app/app.component';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err: Error): void => console.error(err.stack));
bootstrapApplication(AppComponent, appConfig).catch((err: Error): void => console.error(err.stack));
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://fraujulian.xyz/sitemap.xml
-47
View File
@@ -1,47 +0,0 @@
import {APP_BASE_HREF} from '@angular/common';
import {CommonEngine, isMainModule} from '@angular/ssr/node';
import express, {Express, NextFunction, Request, Response} from 'express';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import bootstrap from './main.server';
import ProductionConfig from '../configs/Production.json';
const serverDistFolder: string = dirname(fileURLToPath(import.meta.url));
const browserDistFolder: string = resolve(serverDistFolder, '../browser');
const indexHtml: string = join(serverDistFolder, 'index.server.html');
const app: Express = express();
const commonEngine: CommonEngine = new CommonEngine();
app.get(
'**',
express.static(browserDistFolder, {
maxAge: '1y',
index: 'index.html'
}),
);
app.get('**', (req: Request, res: Response, next: NextFunction): void => {
const {protocol, originalUrl, baseUrl, headers} = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{provide: APP_BASE_HREF, useValue: baseUrl}],
})
.then((html: string): Response => res.send(html))
.catch((err: Error): void => next(err.stack));
});
if (isMainModule(import.meta.url)) {
const port: number = ProductionConfig.Server.Port;
app.listen(port, (): void => {
console.log(`✔️ Listening on Port ${port}`);
});
}
export default app;
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://fraujulian.xyz</loc>
<lastmod>2026-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://fraujulian.xyz/?lang=de</loc>
<lastmod>2026-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://fraujulian.xyz/imprint</loc>
<lastmod>2026-10-15</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://fraujulian.xyz/imprint?lang=de</loc>
<lastmod>2026-10-15</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+1483 -442
View File
File diff suppressed because it is too large. Load diff
+3 -11
View File
@@ -4,16 +4,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [
"node"
]
"types": []
},
"files": [
"src/main.ts",
"src/main.server.ts",
"src/server.ts"
],
"include": [
"src/**/*.d.ts"
]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more