Compare commits

...
Author SHA1 Message Date
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
fraujulianandGitHub 61b5de597f Delete .claude/settings.local.json 2026-04-27 19:07:17 +02:00
fraujulianandGitHub 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
101 changed files with 17421 additions and 5847 deletions
+34
View File
@@ -0,0 +1,34 @@
# Git- und Projekt-Metadaten werden nicht in den Docker-Build-Kontext kopiert
.git
.github
.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
+42
View File
@@ -0,0 +1,42 @@
name: 🐛 Bug Report
description: Report a bug or unexpected behavior
title: '🐛 [BUG]: '
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
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+29
View File
@@ -0,0 +1,29 @@
name: 💡 Enhancement
description: Suggest a new feature or improvement
title: '💡 [ENHANCEMENT]: '
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
+39
View File
@@ -0,0 +1,39 @@
name: ⚙️ Improvement
description: Suggest an improvement to existing functionality, code quality, or performance
title: '⚙️ [Improvement]: '
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
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
-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 }}
+61
View File
@@ -0,0 +1,61 @@
name: Build Docker Image - Dev
on:
workflow_dispatch:
push:
branches:
- main
env:
REGISTRY: docker.lechner-systems.at
IMAGE_NAME: lechnersystems/portfolio
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: Set build version
run: npm run version:set
- name: Read version from package.json
id: version
run: |
version=$(node -p 'require("./package.json").version')
docker_tag=$version
revision=${GITHUB_RUN_ATTEMPT}
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$GITHUB_OUTPUT"
echo "revision=$revision" >> "$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: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and push dev 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: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
+95
View File
@@ -0,0 +1,95 @@
name: Build Docker Image - Latest
on:
workflow_dispatch:
env:
REGISTRY: docker.lechner-systems.at
IMAGE_NAME: lechnersystems/portfolio
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: Set build version
run: npm run version:set
- name: Read version from package.json
id: version
run: |
version=$(node -p 'require("./package.json").version')
docker_tag=${version//+/-}
revision=${GITHUB_RUN_ATTEMPT}
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$GITHUB_OUTPUT"
echo "revision=$revision" >> "$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: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and push latest 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: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: v${{ steps.version.outputs.docker_tag }}
run: |
previous_tag="$(gh api repos/${{ github.repository }}/releases --paginate --jq '.[] | select(.draft == false) | .tag_name' | grep -Fxv "$RELEASE_TAG" | head -n 1 || true)"
if [ -n "$previous_tag" ]; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITHUB_SHA}")"
full_changelog="https://github.com/${{ github.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_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
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file release-notes.md
else
gh release create "$RELEASE_TAG" --target "${GITHUB_SHA}" --title "$RELEASE_TAG" --notes-file release-notes.md
fi
+32
View File
@@ -0,0 +1,32 @@
name: Build Validation
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: install Dependencies
run: npm install
- name: run Build Validation
run: npm run check
- name: run Tests
run: npm run test
env:
CHROME_BIN: google-chrome
+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 21, TypeScript, SCSS,
Karma/Jasmine 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 Karma/Jasmine.
- `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.14.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.14.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 v21 — 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://github.com/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
+28 -22
View File
@@ -1,10 +1,12 @@
# 🖼️ 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)
### [Website Preview](https://fraujulian.xyz/)
## 👂 Languages/Framework/Packages:
- Angular v19
<img width="2551" height="1175" alt="Portfolio website preview" src="https://github.com/user-attachments/assets/24657c0a-4f95-4573-8472-092984d6cc4e" />
## 👂 Languages / Framework / Packages
- Angular v21
- TypeScript
- SCSS
@@ -19,42 +21,46 @@ npm run dev
### Building
```bash
npm run buildDevelopment
npm run build
```
or for Production Build
```bash
npm run buildProduction
```
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 will compile your project and store the build artifacts in the `dist/`
directory. The production build optimizes your application for performance and
speed.
### Start compiled Project
```bash
npm run start
npm run start
```
## Running unit tests
## 🔍 Code Quality
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
### Lint & Format check
```bash
nom run test
npm run check
```
## Update Packages/Libraries
### Auto-fix lint & format issues
```bash
npm run updatePackageVersions
npm run fix
```
## 📋 Credits:
~ made by [**FrauJulian**](https://fraujulian.xyz/).
## 🧪 Running Unit Tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test
runner, use the following command:
```bash
npm run test
```
## 🤝 Enjoy?
Give it a star ⭐ on [github](https://github.com/FrauJulian/discord-audio-stream)!
~ made by [**FrauJulian**](https://fraujulian.xyz/).
Give it a star ⭐ on [GitHub](https://github.com/FrauJulian/Personal-Portfolio-Website)!
### Greetings from Austria! ⛰️
+81 -25
View File
@@ -17,30 +17,43 @@
"build": {
"builder": "@angular-devkit/build-angular: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": "src/assets/optimized",
"output": "assets/optimized"
},
{
"glob": "**/*",
"input": "public"
}
],
"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 +61,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,13 +85,23 @@
"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
}
},
@@ -99,23 +128,24 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"watch": false,
"browsers": "ChromeHeadless",
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets/optimized",
"output": "assets/optimized"
},
{
"glob": "**/*",
"input": "public",
"output": "public"
}
],
"styles": [
"node_modules/@angular/material/prebuilt-themes/rose-red.css",
"src/styles.scss"
],
"styles": ["src/styles.scss"],
"scripts": []
}
}
@@ -124,5 +154,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;
```
+10222 -4049
View File
File diff suppressed because it is too large Load Diff
+87 -42
View File
@@ -1,65 +1,110 @@
{
"name": "angular-portfolio-seite",
"version": "3.1.11",
"main": "src/server.ts",
"name": "portfolio",
"version": "YYDDDhhmm+GIT_HASH",
"currentVersion": "3.2.0",
"engines": {
"node": ">=24.14.0",
"npm": ">=11.12.0"
},
"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"
"------ 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:openpgp-assets": "node scripts/generate-openpgp-assets.cjs",
"generate:assets": "npm run generate:static-files && npm run generate:optimized-assets && npm run generate:openpgp-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://github.com/FrauJulian/Personal-Portfolio-Website",
"bugs": "https://github.com/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/animations": "^21.2.13",
"@angular/cdk": "^21.2.11",
"@angular/common": "^21.2.13",
"@angular/compiler": "^21.2.13",
"@angular/core": "^21.2.13",
"@angular/forms": "^21.2.13",
"@angular/material": "^21.2.11",
"@angular/platform-browser": "^21.2.13",
"@angular/platform-browser-dynamic": "^21.2.13",
"@angular/platform-server": "^21.2.13",
"@angular/router": "^21.2.13",
"@angular/ssr": "^21.2.11",
"@types/markdown-it": "^14.1.2",
"express": "^5.2.1",
"markdown-it": "^14.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
"zone.js": "~0.16.2"
},
"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",
"@angular-devkit/build-angular": "^21.2.11",
"@angular/cli": "^21.2.11",
"@angular/compiler-cli": "^21.2.13",
"@types/express": "^5.0.6",
"@types/jasmine": "~6.0.0",
"@types/node": "^25.7.0",
"@types/showdown": "^2.0.6",
"del-cli": "^6.0.0",
"jasmine-core": "~5.6.0",
"@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.3",
"angular-eslint": "21.4.0",
"del-cli": "^7.0.0",
"esbuild": "^0.28.0",
"eslint": "9.39.4",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "18.0.1",
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-unused-imports": "4.4.1",
"husky": "^9.1.7",
"jasmine-core": "~6.2.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"
"karma-jasmine-html-reporter": "~2.2.0",
"postcss": "^8.5.14",
"postcss-selector-parser": "^7.1.1",
"prettier": "^3.8.1",
"sharp": "^0.34.5",
"typescript": "~5.9.3",
"typescript-eslint": "8.59.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: 264 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);
+180
View File
@@ -0,0 +1,180 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const workspaceRoot = process.cwd();
const sourceKeyPath = path.join(workspaceRoot, 'src', 'assets', 'unoptimized', 'public.asc');
const publicKeyOutputPath = path.join(workspaceRoot, 'public', 'keys', 'public.asc');
const generatedModulePath = path.join(
workspaceRoot,
'src',
'app',
'generated',
'openpgp-key.generated.ts',
);
function ensureDirectory(directoryPath) {
fs.mkdirSync(directoryPath, { recursive: true });
}
function readAsciiArmoredKey(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`Missing OpenPGP public key file at ${filePath}`);
}
return fs.readFileSync(filePath, 'utf8');
}
function decodeArmoredKey(armoredKey) {
const base64Lines = armoredKey
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(
(line) =>
line.length > 0 &&
!line.startsWith('-----BEGIN ') &&
!line.startsWith('-----END ') &&
!line.includes(':') &&
!line.startsWith('='),
);
if (base64Lines.length === 0) {
throw new Error('OpenPGP public key armor did not contain any base64 payload.');
}
return Buffer.from(base64Lines.join(''), 'base64');
}
function parsePacketLength(buffer, offset, newFormat, lengthType) {
if (newFormat) {
const firstLengthOctet = buffer[offset];
if (firstLengthOctet < 192) {
return { headerLength: 1, packetLength: firstLengthOctet };
}
if (firstLengthOctet < 224) {
const secondLengthOctet = buffer[offset + 1];
const packetLength = ((firstLengthOctet - 192) << 8) + secondLengthOctet + 192;
return { headerLength: 2, packetLength };
}
if (firstLengthOctet === 255) {
return {
headerLength: 5,
packetLength: buffer.readUInt32BE(offset + 1),
};
}
throw new Error('OpenPGP partial body lengths are not supported by this generator.');
}
if (lengthType === 0) {
return { headerLength: 1, packetLength: buffer[offset] };
}
if (lengthType === 1) {
return { headerLength: 2, packetLength: buffer.readUInt16BE(offset) };
}
if (lengthType === 2) {
return { headerLength: 4, packetLength: buffer.readUInt32BE(offset) };
}
return { headerLength: 0, packetLength: buffer.length - offset };
}
function readPackets(buffer) {
const packets = [];
let offset = 0;
while (offset < buffer.length) {
const headerOctet = buffer[offset];
if ((headerOctet & 0x80) === 0) {
throw new Error(`Invalid OpenPGP packet header at byte offset ${offset}.`);
}
offset += 1;
const newFormat = (headerOctet & 0x40) !== 0;
const tag = newFormat ? headerOctet & 0x3f : (headerOctet >> 2) & 0x0f;
const lengthType = headerOctet & 0x03;
const { headerLength, packetLength } = parsePacketLength(buffer, offset, newFormat, lengthType);
offset += headerLength;
const body = buffer.subarray(offset, offset + packetLength);
packets.push({ tag, body });
offset += packetLength;
}
return packets;
}
function calculateFingerprintFromPacket(publicKeyPacketBody) {
const version = publicKeyPacketBody[0];
if (version === 4) {
const lengthBytes = Buffer.alloc(2);
lengthBytes.writeUInt16BE(publicKeyPacketBody.length, 0);
return crypto
.createHash('sha1')
.update(Buffer.concat([Buffer.from([0x99]), lengthBytes, publicKeyPacketBody]))
.digest('hex')
.toUpperCase();
}
if (version === 5 || version === 6) {
const lengthBytes = Buffer.alloc(4);
lengthBytes.writeUInt32BE(publicKeyPacketBody.length, 0);
return crypto
.createHash('sha256')
.update(Buffer.concat([Buffer.from([0x9a]), lengthBytes, publicKeyPacketBody]))
.digest('hex')
.toUpperCase();
}
throw new Error(`Unsupported OpenPGP public key version ${version}.`);
}
function formatFingerprint(fingerprint) {
return fingerprint.match(/.{1,4}/gu)?.join(' ') ?? fingerprint;
}
function buildGeneratedModule(assetPath, fingerprint) {
return [
'export const openPgpKey = {',
` assetPath: '${assetPath}',`,
` fingerprint: '${fingerprint}',`,
` formattedFingerprint: '${formatFingerprint(fingerprint)}',`,
'} as const;',
'',
].join('\n');
}
function run() {
const armoredKey = readAsciiArmoredKey(sourceKeyPath);
const binaryKey = decodeArmoredKey(armoredKey);
const packets = readPackets(binaryKey);
const publicKeyPacket = packets.find((packet) => packet.tag === 6);
if (!publicKeyPacket) {
throw new Error('Could not find a public-key packet in the provided OpenPGP key.');
}
const fingerprint = calculateFingerprintFromPacket(publicKeyPacket.body);
ensureDirectory(path.dirname(publicKeyOutputPath));
ensureDirectory(path.dirname(generatedModulePath));
fs.copyFileSync(sourceKeyPath, publicKeyOutputPath);
fs.writeFileSync(
generatedModulePath,
buildGeneratedModule('/keys/public.asc', fingerprint),
'utf8',
);
console.log(`Copied ${path.relative(workspaceRoot, publicKeyOutputPath)}`);
console.log(`Generated ${path.relative(workspaceRoot, generatedModulePath)}`);
console.log(`OpenPGP fingerprint: ${formatFingerprint(fingerprint)}`);
}
run();
+191
View File
@@ -0,0 +1,191 @@
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']);
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',
};
}
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)`,
);
}
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,
};
+114
View File
@@ -0,0 +1,114 @@
import { createReadStream } from 'node:fs';
import { access, stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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 mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.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'],
['.xml', 'application/xml; charset=utf-8'],
]);
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', '.js', '.json', '.map', '.png', '.svg', '.txt', '.webp', '.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 pathExists(targetPath) {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
async function resolveFile(urlPath) {
const normalized = path.posix.normalize(decodeURIComponent(urlPath));
const relativePath = normalized.replace(/^(\.\.(\/|\\|$))+/, '').replace(/^\/+/, '');
const rootCandidate = path.join(distRoot, relativePath);
if ((await pathExists(rootCandidate)) && (await stat(rootCandidate)).isFile()) {
return rootCandidate;
}
const browserCandidate = path.join(browserRoot, relativePath);
if ((await pathExists(browserCandidate)) && (await stat(browserCandidate)).isFile()) {
return browserCandidate;
}
if (!path.posix.extname(normalized)) {
return path.join(browserRoot, 'index.html');
}
return null;
}
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
const filePath = await resolveFile(requestUrl.pathname);
if (!filePath) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
response.writeHead(200, {
'Cache-Control': getCacheControl(filePath),
'Content-Type': getContentType(filePath),
});
createReadStream(filePath).pipe(response);
} catch (error) {
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Internal server error');
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 @@
module.exports = {
siteUrl: 'https://fraujulian.xyz',
sitemap: {
routes: [
{
path: '/',
lastModified: '2026-04-17',
changeFrequency: 'monthly',
priority: '1.0',
},
],
},
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" [attr.aria-hidden]="isLanguageSelectorOpen() ? 'true' : null" [attr.inert]="isLanguageSelectorOpen() ? '' : null">
<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>
@if (isLanguageSelectorOpen()) {
<div
class="language-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="language-title"
(click)="closeLanguageSelectorOnBackdrop($event)"
>
<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>
</div>
}
+83 -19
View File
@@ -1,30 +1,94 @@
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('.language-overlay')).toBeNull();
});
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;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean;
};
comp.chooseLanguage('de');
fixture.detectChanges();
expect(comp.currentLanguageCode()).toBe('de');
expect(comp.isLanguageConfirmed()).toBeTrue();
expect(comp.isLanguageSelectorOpen()).toBeFalse();
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;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean;
};
expect(restoredComponent.currentLanguageCode()).toBe('de');
expect(restoredComponent.isLanguageConfirmed()).toBeTrue();
expect(restoredComponent.isLanguageSelectorOpen()).toBeFalse();
});
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()).toBeTrue();
});
});
+86 -8
View File
@@ -1,13 +1,91 @@
import {Component} from '@angular/core'
import {RouterOutlet} from '@angular/router';
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import type { OnDestroy, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import type { LanguageCode } from '../languages/language.types';
import type { LanguageOption } from './app.types';
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, OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly shellLanguageService = inject(ShellLanguageService);
protected readonly content = this.shellLanguageService.content;
protected readonly currentLanguageCode = this.shellLanguageService.languageCode;
protected readonly isLanguageConfirmed = this.shellLanguageService.isLanguageConfirmed;
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()),
);
constructor() {
effect((): void => {
const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : '';
this.document.body.style.overflow = overflowValue;
this.document.documentElement.style.overflow = overflowValue;
});
}
ngOnInit(): void {
const initialLanguage = this.shellLanguageService.initializeFromStorage();
this.selectedLanguage.set(initialLanguage);
}
protected chooseLanguage(language: LanguageCode): void {
this.selectedLanguage.set(language);
this.confirmLanguage();
}
protected confirmLanguage(): void {
this.shellLanguageService.confirmLanguage(this.selectedLanguage());
this.isLanguageSelectorOpen.set(false);
}
protected reopenLanguageSelector(): void {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(true);
}
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;
}
ngOnDestroy(): void {
this.document.body.style.overflow = '';
this.document.documentElement.style.overflow = '';
}
}
-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);
+13 -14
View File
@@ -1,19 +1,18 @@
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 { 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';
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',
}),
),
],
};
+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 -10
View File
@@ -1,16 +1,14 @@
<noscript>
<div class="container">
<div class="card">
<h2 id="loading-text" class="warning-text">
Enable javascript for the full experience!
</h2>
<div class="page-shell footer-shell">
<div class="panel noscript-panel">
<h2 id="loading-text" class="warning-text">{{ content().footer.noscriptMessage }}</h2>
</div>
</div>
</noscript>
<footer>
<p>
<a routerLink="/imprint">IMPRINT</a> | &copy; 2023
- {{ currentYear }} {{ globalFields.firstname }} {{ globalFields.lastname }} - All rights reserved.
</p>
<footer class="site-footer">
<div class="footer-inner">
<p>&copy; 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
</div>
</footer>
+88 -6
View File
@@ -1,23 +1,105 @@
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 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);
});
});
});
+12 -9
View File
@@ -1,16 +1,19 @@
import {Component} from '@angular/core';
import {RouterLink} from "@angular/router";
import {globalFields} from '../../global.fields';
import { ChangeDetectionStrategy, Component, inject } 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();
}
@@ -0,0 +1,5 @@
export const openPgpKey = {
assetPath: '/keys/public.asc',
fingerprint: '98FA30F85F2030A70F45AAEB3CDEA2DE5424B8E7',
formattedFingerprint: '98FA 30F8 5F20 30A7 0F45 AAEB 3CDE A2DE 5424 B8E7',
} as const;
+162 -138
View File
@@ -1,146 +1,170 @@
<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 Im 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-contact-block">
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactMailHref" aria-label="Send mail"><app-icon [icon]="envelopeIcon" size="lg"></app-icon></a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
><app-icon [icon]="phoneIcon" size="lg"></app-icon
></a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
><app-icon [icon]="discordIcon" size="lg"></app-icon
></a>
<a href="https://github.com/fraujulian" aria-label="GitHub profile"><app-icon [icon]="githubIcon" size="lg"></app-icon></a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
></a>
</div>
<p>{{ project.Description }}</p>
</div>
<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>
<div class="hero-meta">
<div>
<span class="meta-label">{{ currentBioEntry.label }}</span
><strong>{{ currentBioEntry.value }}</strong>
</div>
</div>
</div>
</div>
</div>
<div class="hero-visual">
@if (currentPortraitHighlight) {
<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]="currentPortraitHighlight.image"
[attr.srcset]="currentPortraitHighlight.imageSrcset"
[attr.sizes]="currentPortraitHighlight.imageSizes"
[width]="currentPortraitHighlight.imageWidth"
[height]="currentPortraitHighlight.imageHeight"
fetchpriority="high"
decoding="async"
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
/>
@if (currentPortraitHighlightIndex === 0) {
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
}
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
</div>
</button>
</div>
}
</div>
</section>
<section class="panel openpgp-section">
<div class="openpgp-copy">
<p class="eyebrow">{{ content().home.openPgpTitle }}</p>
<p class="openpgp-description">{{ content().home.openPgpDescription }}</p>
</div>
<div class="openpgp-actions">
<a class="action-link openpgp-download-link" [href]="openPgpKey.assetPath" download="julian-lechner-public.asc">
{{ content().home.openPgpDownload }}
</a>
<p class="openpgp-fingerprint">
<span>{{ content().home.openPgpFingerprintLabel }}</span>
<strong>{{ openPgpKey.formattedFingerprint }}</strong>
</p>
</div>
</section>
<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"
[ngSrc]="project.icon"
[attr.srcset]="project.iconSrcset ?? null"
[attr.sizes]="project.iconSizes ?? null"
[width]="project.iconWidth ?? 216"
[height]="project.iconHeight ?? 216"
disableOptimizedSrcset
[alt]="project.title + ' icon'"
/>
}
<div class="project-copy">
<div class="project-header">
<div class="project-title-wrap">
<span class="project-link">{{ project.title }}</span>
</div>
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel">
@for (language of project.skills; track language + '-' + $index) {
<span class="project-language-chip">{{ language }}</span>
}
</div>
</div>
<p class="project-description">{{ project.description }}</p>
</div>
</a>
}
</div>
</section>
</main>
<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>
</div>
</div>
<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>
+519 -6
View File
@@ -1,23 +1,536 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { IMAGE_LOADER } from '@angular/common';
import type { ImageLoaderConfig } from '@angular/common';
import { NgZone } from '@angular/core';
import {HomeComponent} from './home.component';
import { HomeComponent } from './home.component';
import { global } from '../../global';
import { enLanguage } from '../../languages/en';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
} from '../../languages/language.types';
describe('HomeComponent', (): void => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
interface ComponentAccess {
age: number;
currentIndex: number;
isLongBioShown: boolean;
isLongBioMounted: boolean;
isPortraitSwitching: boolean;
currentPortraitHighlightIndex: number;
bioHideTimeoutId: number | null;
scrollAnimationFrameId: number | null;
projectEntryAnimationFrameId: number | null;
aboutSectionAnimationFrameId: number | null;
scheduleNameGradientUpdate(): void;
scheduleProjectEntryRevealUpdate(): void;
scheduleAboutSectionScrollAnimationUpdate(): void;
shouldEnableScrollEffects(): boolean;
toggleBio(): void;
showNextPortraitHighlight(): void;
scrollToAboutSection(): void;
scrollToProjectsSection(): void;
scrollToContactLinks(): void;
currentBioEntry: LanguageBioTextEntry;
currentPortraitHighlight: LanguagePortraitHighlight | null;
}
let comp: ComponentAccess;
beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({
imports: [HomeComponent]
})
.compileComponents();
imports: [HomeComponent],
providers: [
provideRouter([]),
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig): string => config.src,
},
],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
comp = component as unknown as ComponentAccess;
fixture.detectChanges();
});
afterEach((): void => {
// Explicitly destroy to stop the interval(1000) subscription started in ngOnInit.
// Without this, the real setInterval keeps the browser's event loop alive after
// the test suite completes, causing Karma to disconnect with a 30 s timeout.
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('currentBioEntry getter', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex = 0;
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
it('should return the correct entry when index changes', (): void => {
comp.currentIndex = 3;
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]);
});
it('should return the fallback entry when index is out of range', (): void => {
comp.currentIndex = 99999;
expect(comp.currentBioEntry.label).toBeDefined();
expect(comp.currentBioEntry.value).toBeDefined();
});
});
describe('interval-driven bio cycling', (): void => {
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.currentBioEntry).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 = last;
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
expect(comp.currentIndex).toBe(0);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
});
// ── Portrait highlight ─────────────────────────────────────────────────────
describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex = 0;
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]);
});
it('should return null when index is out of range', (): void => {
comp.currentPortraitHighlightIndex = 99999;
expect(comp.currentPortraitHighlight).toBeNull();
});
});
describe('showNextPortraitHighlight', (): void => {
// Timer tests run outside Angular zone to prevent NgOptimizedImage from
// throwing NG02953 when height/width inputs change during re-render.
it('should set isPortraitSwitching to true immediately', (): void => {
comp.isPortraitSwitching = false;
comp.showNextPortraitHighlight();
expect(comp.isPortraitSwitching).toBeTrue();
});
it('should not advance index when already switching', (): void => {
comp.isPortraitSwitching = true;
comp.showNextPortraitHighlight();
expect(comp.currentPortraitHighlightIndex).toBe(0);
});
it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] })
.portraitHighlights;
const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry
const setSpy = 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', fakeAsync((): void => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
tick(125);
expect(comp.currentPortraitHighlightIndex).toBe(1);
tick(125);
discardPeriodicTasks();
}));
it('should reset isPortraitSwitching to false after 250 ms', fakeAsync((): void => {
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
tick(250);
expect(comp.isPortraitSwitching).toBeFalse();
discardPeriodicTasks();
}));
it('should wrap portrait index from the last position back to 0', fakeAsync((): void => {
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
});
tick(125);
expect(comp.currentPortraitHighlightIndex).toBe(0);
tick(125);
discardPeriodicTasks();
}));
});
// ── Bio toggle ─────────────────────────────────────────────────────────────
describe('toggleBio', (): void => {
it('should mount bio immediately on opening', fakeAsync((): void => {
comp.toggleBio();
expect(comp.isLongBioMounted).toBeTrue();
tick(100);
discardPeriodicTasks();
}));
it('should set isLongBioShown to true after the double-rAF delay', fakeAsync((): void => {
comp.toggleBio();
tick(50); // allow both nested requestAnimationFrame calls to execute
fixture.detectChanges();
expect(comp.isLongBioShown).toBeTrue();
discardPeriodicTasks();
}));
it('should set isLongBioShown to false immediately on close', fakeAsync((): void => {
// Open first
comp.toggleBio();
tick(50);
fixture.detectChanges();
// Close
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioShown).toBeFalse();
tick(300);
discardPeriodicTasks();
}));
it('should keep isLongBioMounted true during the 300 ms close animation', fakeAsync((): void => {
comp.toggleBio();
tick(50);
fixture.detectChanges();
comp.toggleBio();
fixture.detectChanges();
expect(comp.isLongBioMounted).toBeTrue();
tick(299);
expect(comp.isLongBioMounted).toBeTrue();
tick(1);
fixture.detectChanges();
expect(comp.isLongBioMounted).toBeFalse();
discardPeriodicTasks();
}));
it('should cancel pending close timeout when re-opened during animation', fakeAsync((): void => {
comp.toggleBio();
tick(50);
fixture.detectChanges();
comp.toggleBio(); // start closing
tick(150); // halfway through 300 ms
comp.toggleBio(); // re-open before unmount
tick(50);
fixture.detectChanges();
expect(comp.isLongBioMounted).toBeTrue();
expect(comp.isLongBioShown).toBeTrue();
tick(500);
discardPeriodicTasks();
}));
});
// ── Scroll helpers ─────────────────────────────────────────────────────────
describe('scroll helpers', (): void => {
let scrollSpy: jasmine.Spy;
beforeEach((): void => {
scrollSpy = spyOn(window, 'scrollTo');
});
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', fakeAsync((): void => {
comp.showNextPortraitHighlight();
const clearSpy = spyOn(window, 'clearTimeout').and.callThrough();
fixture.destroy();
expect(clearSpy).toHaveBeenCalled();
discardPeriodicTasks();
}));
it('should not throw when destroyed', fakeAsync((): void => {
expect((): void => fixture.destroy()).not.toThrow();
discardPeriodicTasks();
}));
});
// ── 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 render the bio label from the current bioTextsList entry', (): 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 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 => {
spyOn(window, 'matchMedia').and.callFake(
(query: string): MediaQueryList =>
({
matches:
query !== '(prefers-reduced-motion: reduce)' &&
query !== '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBeTrue();
});
it('should keep scroll effects enabled on coarse touch pointers when motion is allowed', (): void => {
spyOn(window, 'matchMedia').and.callFake(
(query: string): MediaQueryList =>
({
matches: query === '(hover: none) and (pointer: coarse)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBeTrue();
});
it('should disable scroll effects when reduced motion is requested', (): void => {
spyOn(window, 'matchMedia').and.callFake(
(query: string): MediaQueryList =>
({
matches: query === '(prefers-reduced-motion: reduce)',
}) as MediaQueryList,
);
expect(comp.shouldEnableScrollEffects()).toBeFalse();
});
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 deduplicate rapid schedule calls to a single requestAnimationFrame', fakeAsync((): void => {
// The RAF registered during beforeEach's ngOnInit lives outside this fakeAsync zone.
// Resetting the ID to null makes the schedule function treat the slot as free.
comp.scrollAnimationFrameId = null;
let rafCallCount = 0;
spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++;
return rafCallCount; // returns non-null so subsequent calls deduplicate
});
comp.scheduleNameGradientUpdate();
comp.scheduleNameGradientUpdate();
comp.scheduleNameGradientUpdate();
expect(rafCallCount).toBe(1);
discardPeriodicTasks();
}));
it('should deduplicate project-entry reveal scheduling the same way', fakeAsync((): void => {
comp.projectEntryAnimationFrameId = null;
let rafCallCount = 0;
spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++;
return rafCallCount;
});
comp.scheduleProjectEntryRevealUpdate();
comp.scheduleProjectEntryRevealUpdate();
comp.scheduleProjectEntryRevealUpdate();
expect(rafCallCount).toBe(1);
discardPeriodicTasks();
}));
it('should deduplicate about-section scroll scheduling the same way', fakeAsync((): void => {
comp.aboutSectionAnimationFrameId = null;
let rafCallCount = 0;
spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++;
return rafCallCount;
});
comp.scheduleAboutSectionScrollAnimationUpdate();
comp.scheduleAboutSectionScrollAnimationUpdate();
comp.scheduleAboutSectionScrollAnimationUpdate();
expect(rafCallCount).toBe(1);
discardPeriodicTasks();
}));
});
});
+354 -217
View File
@@ -1,251 +1,388 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
NgZone,
OnDestroy,
OnInit,
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 { NgOptimizedImage } from '@angular/common';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { global } from '../../global';
import { openPgpKey } from '../generated/openpgp-key.generated';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
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,
discordIcon,
envelopeIcon,
githubIcon,
linkedinIcon,
phoneIcon,
} from '../shared/icon/icons';
@Component({
selector: 'app-home',
standalone: true,
imports: [
NgOptimizedImage,
FooterComponent,
NgIf,
FaIconComponent,
NgForOf
],
imports: [FooterComponent, IconComponent, NgOptimizedImage],
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 OnInit, AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly age: number | string = this.calculateAge('2009-03-03');
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
private aboutSectionAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress();
});
};
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return;
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
this.projectEntryAnimationFrameId = null;
this.updateProjectEntryRevealProgress();
});
};
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return;
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
this.aboutSectionAnimationFrameId = null;
this.updateAboutSectionScrollProgress();
});
};
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 global = global;
protected readonly age = this.calculateAge(global.birthdate);
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected readonly openPgpKey = openPgpKey;
protected readonly arrowRightIcon = arrowRightIcon;
protected readonly arrowDownIcon = arrowDownIcon;
protected readonly envelopeIcon = envelopeIcon;
protected readonly phoneIcon = phoneIcon;
protected readonly discordIcon = discordIcon;
protected readonly githubIcon = githubIcon;
protected readonly linkedinIcon = linkedinIcon;
protected readonly contactSafeMail: SafeUrl;
private readonly isLongBioShownState = signal(false);
private readonly isLongBioMountedState = signal(false);
private readonly currentPortraitHighlightIndexState = signal(0);
private readonly isPortraitSwitchingState = signal(false);
private readonly currentIndexState = signal(0);
private bioHideTimeoutId: number | null = null;
private portraitSwitchTimeoutId: number | null = null;
private portraitSwitchDoneTimeoutId: number | null = null;
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 get isLongBioShown(): boolean {
return this.isLongBioShownState();
}
protected set isLongBioShown(value: boolean) {
this.isLongBioShownState.set(value);
}
protected get isLongBioMounted(): boolean {
return this.isLongBioMountedState();
}
protected set isLongBioMounted(value: boolean) {
this.isLongBioMountedState.set(value);
}
protected get currentPortraitHighlightIndex(): number {
return this.currentPortraitHighlightIndexState();
}
protected set currentPortraitHighlightIndex(value: number) {
this.currentPortraitHighlightIndexState.set(value);
}
protected get isPortraitSwitching(): boolean {
return this.isPortraitSwitchingState();
}
protected set isPortraitSwitching(value: boolean) {
this.isPortraitSwitchingState.set(value);
}
protected get currentIndex(): number {
return this.currentIndexState();
}
protected set currentIndex(value: number) {
this.currentIndexState.set(value);
}
protected get portraitHighlights(): LanguagePortraitHighlight[] {
return this.content().portraitHighlights;
}
protected get projects(): LanguageProject[] {
return this.content().projects;
}
protected get currentPortraitHighlight(): LanguagePortraitHighlight | null {
return this.portraitHighlights[this.currentPortraitHighlightIndexState()] ?? null;
}
protected get currentBioEntry(): LanguageBioTextEntry {
return (
this.content().bioTextsList[this.currentIndexState()] ?? {
label: this.content().home.fallbackBioLabel,
value: '',
}
);
}
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}`);
protected interpolate(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{age}}', String(this.age));
}
ngOnInit(): void {
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
this.currentIndex = (this.currentIndex + 1) % this.bioTextsList.length;
this.initNameGradientScrollAnimation();
});
this.zone.runOutsideAngular((): void => {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((): void => {
this.zone.run((): void => {
const len = this.content().bioTextsList.length;
this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
});
});
});
setTimeout((): void => {
this.zone.run((): void => {
this.fillProjects();
});
}, 1000);
});
}
ngAfterViewInit(): void {
this.zone.runOutsideAngular((): void => {
this.initProjectEntryRevealObserver();
this.initAboutSectionScrollAnimation();
});
}
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 (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.clearPortraitSwitchTimeouts();
this.destroyNameGradientScrollAnimation();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
}
protected toggleBio(): void {
this.isLongBioShown = !this.isLongBioShown;
}
protected closeOnOverlayClick(evt: MouseEvent): void {
if ((evt.target as HTMLElement).classList.contains('long-bio-overlay')) {
if (this.isLongBioShown) {
this.isLongBioShown = false;
if (typeof window !== 'undefined') {
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = window.setTimeout((): void => {
this.isLongBioMounted = false;
this.bioHideTimeoutId = null;
}, 300);
} else {
this.isLongBioMounted = false;
}
return;
}
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.isLongBioMounted = true;
if (typeof window !== 'undefined') {
window.requestAnimationFrame((): void => {
window.requestAnimationFrame((): void => {
this.isLongBioShown = true;
});
});
} else {
this.isLongBioShown = true;
}
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
this.isPortraitSwitching = true;
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
this.portraitSwitchTimeoutId = null;
}, 125);
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
this.isPortraitSwitching = 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));
}
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 shouldEnableScrollEffects(): boolean {
if (typeof window === 'undefined') return false;
const prefersReducedMotion: boolean = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
return !prefersReducedMotion;
}
private initNameGradientScrollAnimation(): void {
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
this.scheduleNameGradientUpdate();
}
private initProjectEntryRevealObserver(): void {
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
}
private destroyProjectEntryRevealObserver(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
if (this.projectEntryAnimationFrameId !== null) {
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
this.projectEntryAnimationFrameId = null;
}
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress');
});
}
private initAboutSectionScrollAnimation(): void {
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
window.addEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
this.scheduleAboutSectionScrollAnimationUpdate();
}
private destroyAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
if (this.aboutSectionAnimationFrameId !== null) {
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
this.aboutSectionAnimationFrameId = null;
}
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
}
private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') return;
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
if (this.scrollAnimationFrameId !== null) {
window.cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
document.documentElement.style.removeProperty('--name-pride-progress');
}
private clearPortraitSwitchTimeouts(): void {
if (typeof window === 'undefined') return;
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 {
if (typeof window === 'undefined') return;
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 {
if (typeof window === 'undefined') return;
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 {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
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);
}
}
+100 -72
View File
@@ -1,76 +1,104 @@
<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>
<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>- Lechner Systems -</p>
<p>{{ global.address.street }} {{ global.address.houseNumber }}</p>
<p>{{ global.address.zip }} {{ global.address.city }}</p>
<p>{{ global.address.state }}</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">
<p class="eyebrow">{{ content().imprint.scopeEyebrow }}</p>
<h2>{{ content().imprint.scopeTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>{{ content().imprint.scopeLeft }}</p>
</div>
<div class="story-detail">
<p>{{ content().imprint.scopeRight }}</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>{{ bind(content().imprint.privacyLeftParagraphs[0]) }}</p>
<p>{{ content().imprint.privacyLeftParagraphs[1] }}</p>
<p>{{ content().imprint.privacyLeftParagraphs[2] }}</p>
</div>
<div class="story-detail">
<p>{{ content().imprint.privacyRightParagraphs[0] }}</p>
<p>{{ content().imprint.privacyRightParagraphs[1] }}</p>
<p>{{ content().imprint.privacyRightParagraphs[2] }}</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>{{ 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 ngSkipHydration></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)).toBeTrue();
});
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,35 @@
import { Injectable, signal } from '@angular/core';
import type { LanguageCode } from '../../languages/language.types';
@Injectable({ providedIn: 'root' })
export class LanguagePreferenceService {
private static readonly storageKey = 'portfolio-language';
private readonly currentLanguageCode = signal<LanguageCode>('en');
private readonly languageConfirmed = signal(true);
readonly languageCode = this.currentLanguageCode.asReadonly();
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
initializeFromStorage(): LanguageCode {
if (typeof window === 'undefined') {
return this.currentLanguageCode();
}
const storedLanguage = window.localStorage.getItem(LanguagePreferenceService.storageKey);
if (storedLanguage === 'de' || storedLanguage === 'en') {
this.currentLanguageCode.set(storedLanguage);
}
return this.currentLanguageCode();
}
confirmLanguage(code: LanguageCode): void {
this.currentLanguageCode.set(code);
this.languageConfirmed.set(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LanguagePreferenceService.storageKey, code);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, computed, inject } from '@angular/core';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import type { LanguageCode, LanguagePack } from '../../languages/language.types';
import { LanguagePreferenceService } from './language-preference.service';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private readonly languagePreferenceService = inject(LanguagePreferenceService);
readonly languageCode = this.languagePreferenceService.languageCode;
readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed;
readonly content = computed<LanguagePack>(() => this.getPack(this.languageCode()));
initializeFromStorage(): LanguageCode {
return this.languagePreferenceService.initializeFromStorage();
}
confirmLanguage(code: LanguageCode): void {
this.languagePreferenceService.confirmLanguage(code);
}
getPack(code: LanguageCode): LanguagePack {
return code === 'de' ? deLanguage : enLanguage;
}
}
@@ -0,0 +1,26 @@
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 languagePreferenceService = inject(LanguagePreferenceService);
readonly languageCode = this.languagePreferenceService.languageCode;
readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed;
readonly content = computed<LanguageShellPack>(() => this.getPack(this.languageCode()));
initializeFromStorage(): LanguageCode {
return this.languagePreferenceService.initializeFromStorage();
}
confirmLanguage(code: LanguageCode): void {
this.languagePreferenceService.confirmLanguage(code);
}
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';
+41
View File
@@ -0,0 +1,41 @@
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',
};
export const discordIcon: SvgIcon = {
viewBox: '0 0 576 512',
path: 'M492.5 69.8c-.2-.3-.4-.6-.8-.7-38.1-17.5-78.4-30-119.7-37.1-.4-.1-.8 0-1.1 .1s-.6 .4-.8 .8c-5.5 9.9-10.5 20.2-14.9 30.6-44.6-6.8-89.9-6.8-134.4 0-4.5-10.5-9.5-20.7-15.1-30.6-.2-.3-.5-.6-.8-.8s-.7-.2-1.1-.2c-41.3 7.1-81.6 19.6-119.7 37.1-.3 .1-.6 .4-.8 .7-76.2 113.8-97.1 224.9-86.9 334.5 0 .3 .1 .5 .2 .8s.3 .4 .5 .6c44.4 32.9 94 58 146.8 74.2 .4 .1 .8 .1 1.1 0s.7-.4 .9-.7c11.3-15.4 21.4-31.8 30-48.8 .1-.2 .2-.5 .2-.8s0-.5-.1-.8-.2-.5-.4-.6-.4-.3-.7-.4c-15.8-6.1-31.2-13.4-45.9-21.9-.3-.2-.5-.4-.7-.6s-.3-.6-.3-.9 0-.6 .2-.9 .3-.5 .6-.7c3.1-2.3 6.2-4.7 9.1-7.1 .3-.2 .6-.4 .9-.4s.7 0 1 .1c96.2 43.9 200.4 43.9 295.5 0 .3-.1 .7-.2 1-.2s.7 .2 .9 .4c2.9 2.4 6 4.9 9.1 7.2 .2 .2 .4 .4 .6 .7s.2 .6 .2 .9-.1 .6-.3 .9-.4 .5-.6 .6c-14.7 8.6-30 15.9-45.9 21.8-.2 .1-.5 .2-.7 .4s-.3 .4-.4 .7-.1 .5-.1 .8 .1 .5 .2 .8c8.8 17 18.8 33.3 30 48.8 .2 .3 .6 .6 .9 .7s.8 .1 1.1 0c52.9-16.2 102.6-41.3 147.1-74.2 .2-.2 .4-.4 .5-.6s.2-.5 .2-.8c12.3-126.8-20.5-236.9-86.9-334.5zm-302 267.7c-29 0-52.8-26.6-52.8-59.2s23.4-59.2 52.8-59.2c29.7 0 53.3 26.8 52.8 59.2 0 32.7-23.4 59.2-52.8 59.2zm195.4 0c-29 0-52.8-26.6-52.8-59.2s23.4-59.2 52.8-59.2c29.7 0 53.3 26.8 52.8 59.2 0 32.7-23.2 59.2-52.8 59.2z',
};
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',
};
export const githubIcon: SvgIcon = {
viewBox: '0 0 512 512',
path: 'M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM252.8 8c-138.7 0-244.8 105.3-244.8 244 0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1 100-33.2 167.8-128.1 167.8-239 0-138.7-112.5-244-251.2-244zM105.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z',
};
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

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: 5.0 MiB

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

+91
View File
@@ -0,0 +1,91 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGoC8R8BEACq5XrqV728z2hYvEZCep8T3729VoFia70l7qoz1o6aDCovlIRG
PGMpcRHin0rqI2pAVHha8bMJnm34JdlPY4Dk/KI31hwLNEoTw8xzfl+58ehEnSvG
o1v3OkXC7bx4zxarFllo4/zgbiKRi1SihXZe/Ytonkm/OX0oHmwEsWlL4Gu2rX+p
Wwg/ks+l6hJ9NiB3D+F9rGfryrFgFTzV90C+1P2bOn/4NXUrr50T92Dn9zXSA1oe
k4ebnZ9df9uRlRaftNtcL/8nY7KGYr1tDBsgL2HWxknzhNoZQiFc0QJ5EjN4dz+s
kkn2aKSlzlOzBGgDeCfk6xMAyLKEewoCtagD0Fo64Mg6W5nPa8lg9xG4soGXBC2d
gUCyQPAcIw7sW71rawuFon12K+Sn02kZ0gHZVtCULh1Z66zTnw6D+fT2khbcAE85
81tuBDVrfv5WjZtSH6t7FqHOmyvLDEPDpudJKOxxuVJfjpfhhXhK17K/8148zlJH
Vmgar3Ilx/Ef0RerDD3boCXEWT0SRyFETY58iK1Vhu5hCojcn10cs/QK8X6RdQ6D
Y2WtbQ7mebqFJWyetuV1ZAFDsNnXtonODBSvhHBtqzI4IL8hFZwiM6cgM3WqfGfk
svTC5O1lePp0L/jrP0fiYjw8wv/f4wEKeM+0xOTkvXbFxlibLxZ9AS8NfQARAQAB
tCNKdWxpYW4gTGVjaG5lciA8anVsaWFuQGxlY2huZXIudG9wPokCUQQTAQgAOxYh
BJj6MPhfIDCnD0Wq6zzeot5UJLjnBQJqAvEfAhsDBQsJCAcCAiICBhUKCQgLAgQW
AgMBAh4HAheAAAoJEDzeot5UJLjnBBYP/ilO+Fmxh3P9IUWC9MHi22RQUDTVUXFY
5FZdRwcwISnZQiEO3OWcwUAPREL2LksWT18Hi+PQRsa/dHU7cePEKRVdfuBl0CU8
P0aUdJA2/zFIAy7CdKZ8gnNcAkkb3xhuKcX1UPgzWP4N2E7qOEt/Vst4JFL8cU3T
/MHQEq7iERtpLSXR/IcI+gcFKDxABYyb073W8rzt0CKAaLFjg2DXPHhFPKn5H6p4
DvXHrKfa5F5YlO+hPKcSA2L73FOmMbB8sT6TVTgPmKce8c/nWfQq3oNF775k1vJk
NBNB6Hy4cVjOOXBMxfAySWnBYaYHNzApXIyH6CUYesSWSY2wfkmlPNSstODah8Gd
YT/DK+ATmjj6JMpM7ELKZvT5iZOaau7NS7bXn9DDiwttFgM9+lxbA6hq75NTMwLF
PyLrl2Yne7y+jr9s4/v/1NxOjDwkpVyJFva4UPwR8RL93F+iomYzG3RTrjTV5j21
+zxM1hdTtbmsdh8HQi9G4XF9QpG62W0uYmTgidfQvPvUDrF041yG2bc+tSh21Xcg
EEnZuRxdKK4EtceJ20dUaG3PCy/oqg8fkGiBoHQJ3NtZDBGbYZIVXMz4mJzPOtPh
3M/7GoodKkCSJ+KedngvgAFSY6QVJCeeW0H3bGpdTYXQIYt8JwEGuUt7yU8ucEsY
NKz7VtAoYhHztCdKdWxpYW4gTGVjaG5lciA8ZnJhdWp1bGlhbkBsZWNobmVyLnRv
cD6JAlEEEwEIADsWIQSY+jD4XyAwpw9Fqus83qLeVCS45wUCagL3pgIbAwULCQgH
AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRA83qLeVCS45zqDD/9pHFvqCrqhF/HH
xcwh3JbXzn8sEZ1vtbrQVrXeJsGo4NNpWem3B29ZVk3P2JHTVXEiBuzkBee+cd5a
TfGTTY9c5HR1Z54j3ydFnCMUNIqqtB+WIeYNr+OU0oNHplLHZYj+MDutqP8MLZMX
X400ITTd4nz7iZmc3cgREIFGbi8QuLRhNEIn6oNkPEqumRnKweLL3TqedyAEtG+k
qsgDAMDb2LzOTWR+YLVM6g9zXmWI8fXxAC2Pda1IeTOCJd+GtldOkkbQjPLNuARi
1uLjZav92vZ/Iw75TTAQzsM9eSuRHv1JxWjUbnNORoiGEQTvyyZ7VHWiQmOLKy8q
z4OGAj2lpSgrSacqK9BD0CXi9y/DNVv1BR+akD5y77KTuF7n0d4E9dWrbr4DkLkx
8ZKyLJfQX3Ma7A0uQkCEwF7epfAuWSGDvB9wCXKUtjyCb6gRMzNOu6JKHZ9pDdO1
Hp8jGyOrs/4+Jx7RvzOeE4HlU8yv86qmjdEy7c1iJGKi3d4CQG5fVvVjZieIOhOv
RUGTgS40CPAWoKD/SIejVvSjRgt6OMS75+/ZnqGZJX7DxWlbEsIDV455Kvitw21f
WhTy8B+Hx+cbAJuRszEO1z3GGcIb61Sy4ctJZjn84wUXgJdspZtwP29a5zHfKH6x
ap9z0lNZREN6G32L5txfvbqJ9js41rQqTGVjaG5lci1TeXN0ZW1zIDxhYnVzZUBs
ZWNobmVyLXN5c3RlbXMuYXQ+iQJRBBMBCAA7FiEEmPow+F8gMKcPRarrPN6i3lQk
uOcFAmoC978CGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQPN6i3lQk
uOdFCA/9GU0NpoYiwajOL8tz+skzu9dZrqdRAgiDYam/iPgYEkqgisULkMBm67dk
TKkJetWjK4zQ+raapRvr+pNMxwm0c88BxHCel+jcOacxOY+rBFKjZB+fVxB6aa0S
epHLU/0gNEF1l325YtwgVgYvwBUaIos5yVnr2CJekWd5Kwijla92QoEsQb4nqAHc
SP6BhTeqPmDPlVwKIXhb82IKRahQZwmB3RVw5XLa9/1ZjUpP9vdX/lxhTWlhcswd
GgM3sQSLtW4THYUr+2esZHwO2+k9D00WiKWFuqOIqLZST3yg9UPdFxEOtGIAsdZ/
eRcljDL/oNeZFT3H8oBbqJ9X1D26kYsVGbWfFbaJPi2Y7rST7eGOJnlPnOfKp9UA
V3P8dIkQSgG++mY5S+TQhibZtceAkuAq/TUtKdk/OhiE9iNkWwwU24dxxNwQgp7u
2WNEsLcW6LoTHW+62cFXHsFAzxy7o5QQPZ4PpCKzdgvLpbqohbIl/jfomYD8BAB1
Yp/XgTGwSoFZ8zFoJ7Blbh7jXpfC4fyRMwzfFHryU8fAwRuVmIR8UFNI2lbzqjjw
xtAagtUYQnml6bg6o8QMJnmwSbYY4C52fbXguCmqyDXK0L1olW/HjHzBGSx9ORBX
04QektjQwcmM8CaM4Kq24H+EoN2Ys3C3UU8fqR8Hw821EefxzLS0K0xlY2huZXIt
U3lzdGVtcyA8b2ZmaWNlQGxlY2huZXItc3lzdGVtcy5hdD6JAlEEEwEIADsWIQSY
+jD4XyAwpw9Fqus83qLeVCS45wUCagL31QIbAwULCQgHAgIiAgYVCgkICwIEFgID
AQIeBwIXgAAKCRA83qLeVCS451ArEACevHcb/8q6zTF/MPFb8BZNVj8x90D/yosN
s9d9W2Gtj+NwGrroi1mvkLEvO2M5O4LLZHu2+mWV7Jfb8YuCADWEBEuwMXdxt5uj
e9INi79MWTXhkgNYPKSd3ockmkPOMTTFoTBBh1Do2YLYO74M+91HeLRCxVn+dVWy
hoWkUQWSGF8bPNRtW6pxCOFc2QnD0bkKer6UfIi9ZXD66ui5EaiKLg4T3XyrviYI
LbDXFj1X92BzEc0ynThNN6e8zHezDiUrNdGzMK5FR55w/MC6j1rcths1SZhCxd7u
gUnlzkIqLcOFUPsEBcYLc1kcODpGLTVEBFvy4WSkeUdc0zbl0ETl+3JaP8J3Lhv/
USdhPdle3eBMA5xfgJGJ1XIljaAc9XCbDCIJ7RzWwE6+6t8rbPOaaR+cpt/rBHKq
hZUyJdoGh/Ixi24EWgv3RGz6XIidgpNMUubU97k69sXJZcSQbKi6w/wUdwcgWRfF
0dpiDg7pxthvhuqw+hPvKe0AF4TMrIPLhn7EzmauFNTKoowhvyHhdi5VOaI21Rkt
yTAl+yQIV0pf3i8KcA7AjGuwXXoDU1lpCKcehJevnv6RUGNZr1SOKxwmGxxS+bFQ
mDUByic2MmAqXY1+FU79YwolAOCe55npOaKNu4A21uizjTUFfjxBhdN71KGovN58
MjYH3sEXQbkCDQRqAvEfARAAuden2frenqKhpwWJSBDBLMaOjop4oPzhrtdoG83j
llqbF1uwXCHfa1C2c4HEg1AFxW1VToMSF7n4DKGyFZgNvbF3I43UeckofV7eQa4U
5/hBvm1OWnJl3eqgz02o6VCuDjELMn0C2muYW+JKQDFmaJx9TbUvHst5HRZv5fhJ
gG/0VibNZhJHCOICxseKkci4oz8J0Rrk/8GHsUcu34RSZ2XxMxAKd7o0QgRNn3dz
P6BKEUfw3M4QEl/YTWZdcdWPfFACAqzBhE3uZpG1nqyeJ4PJvXhGkJwkQimYnbDL
Wm+apl0QidmGyLh2ImxNlSC5PnXVEzpGCoOtSQ9Gfilm5txsyZSWYr7qDNN/dNay
hTcWx2mx7z6CxG3sBMmQVJR755kuMebpaYdrLa4kTSvOZNJqhIStF/ra9YFHwQtx
r/Fcw+MQlnP403JK2CKDBYgck4da0NePywj4Xv5BiE1+sq3NcGZDioFTm7egPjrp
rZ100EPrSmMNZP5AwSk1pbjLunKtPSk950cUDcxNiZtX1bG/kzDxoAKm4uHR5xFm
5hJDJwpBAjfliT/GBgomoHRf8+/sNpZ1/DkQXdh6KXYkDkHtFuqi2zkj7s8ubBja
iWKH0mfYoXx8c9x4SuxLL4bAaW9z1tLvgymOfIYtTNVL0/qBPCwdZ/jmEPg0uf9i
FHUAEQEAAYkCNgQYAQgAIBYhBJj6MPhfIDCnD0Wq6zzeot5UJLjnBQJqAvEfAhsM
AAoJEDzeot5UJLjn33gP/RfRzcPheGBN+e8mNRYZ9AzNGvRfXz5UXkYpYeSOk7OW
YajW7y/xSuifiHW2xfLpHPYcj+6kqEpEzzPUDUq2oADBkYMMy4IpGKyy6bZBg3CU
trbAUrHPUXuO3F7m86SMHWR+5Lb3EucomFQDPRCDJzlv4huWFzltPGyAfGe7jgl4
HuUZPkH/vP32FG73L2+3qsi54FdI7GUkOy/2F7jhaxk44ByiQEJDR7BhvbNNCQXy
M2LkR5BKMUR9j5dIp4lLULGhhOuwGxTgrlS2ExGPcppLo8Rs9EXPAxxfpuPA64e4
w/o6r1ougtcRjecuARqco2cWalIHfXJtOZiYGqAlLuqleMWhjTv/mJYf3o+FsbP5
b9Fi6+bcS9m9HVi06hR/qZsV8Q1dNPkRxotdKLw+/z9yAaXiTRvBY+knq+ImwkgM
rItfmohiXkwOeWpB76m4pzfD/fE4wMEguZgZMVN6iPwYxOL8sFZVSarEq0UqG9fR
eqPMEfwjLe5ADQjuDkxMjA9Zc8tGndNBJEucNDi/Rn17A2uP1qOlFaVln/NWLYwn
Gsjhzbgq9RN22BxGbSmYboLOcCLJ3eAooI6qh02U920yEDEd4lPPodDSw5wj3Sxl
hbuSi9A8NgoBf0QYnVnCOi2Yw81ZZjkwmkIa15NEZNFRDSwjvfbiDgDHpJ7jDskz
=kK3g
-----END PGP PUBLIC KEY BLOCK-----
-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;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Global } from './global.types';
export const global: Global = {
firstname: 'Julian',
lastname: 'Lechner',
contactMail: 'fraujulian@lechner.top',
abuseMail: 'abuse@lechner-systems.at',
contactPhone: '+43 660 9254001',
hrefContactPhone: '+436609254001',
birthdate: '2009-03-03',
address: {
street: 'Ulmenstraße',
houseNumber: 9,
zip: 3380,
city: 'Pöchlarn',
state: 'Niederösterreich',
country: 'Österreich',
},
};
+19
View File
@@ -0,0 +1,19 @@
export interface ImprintData {
street: string;
houseNumber: number;
zip: number;
city: string;
state: string;
country: string;
}
export interface Global {
firstname: string;
lastname: string;
contactMail: string;
abuseMail: string;
contactPhone: string;
hrefContactPhone: string;
birthdate: string;
address: ImprintData;
}
+18 -21
View File
@@ -1,24 +1,21 @@
<!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>
<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>
<link rel="icon" type="image/webp" sizes="128x128" href="assets/optimized/portrait/me-128.webp" />
<title>Lechner Julian</title>
</head>
<body>
<app-root></app-root>
</body>
</html>
+262
View File
@@ -0,0 +1,262 @@
import type { LanguagePack } from './language.types';
export const deLanguage: LanguagePack = {
app: {
selectorTitle: 'Sprache auswählen',
changeLanguage: 'Sprache',
changeLanguageAriaLabel: 'Sprache ändern',
closeSelector: 'Sprachauswahl schließen',
languageEnglish: 'Englisch',
languageGerman: 'Deutsch',
},
footer: {
noscriptMessage: 'Bitte aktiviere JavaScript um alles zu sehen.',
imprintLink: 'Impressum',
},
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',
openPgpTitle: 'OpenPGP',
openPgpDescription: 'Verwende meinen Public Key für verschlüsselte Kommunikation.',
openPgpDownload: 'Public Key herunterladen',
openPgpFingerprintLabel: 'Fingerprint',
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',
projectLanguagesAriaLabel: 'Projekt-Technologien',
fallbackBioLabel: 'Tech Stack',
},
imprint: {
legalEyebrow: 'Rechtliches',
title: 'Impressum & Datenschutz',
intro:
'Rechtliche Informationen nach österreichischem Recht sowie Datenschutzinformationen gemäß DSGVO für diese Website von {{firstname}} {{lastname}}, handelnd unter Lechner Systems.',
backToPortfolio: 'Zurück zum Portfolio',
provider: 'Anbieter',
directContact: 'Direkter Kontakt',
phone: 'Telefon',
contact: 'E-Mail',
abuse: 'Abuse E-Mail',
scopeEyebrow: 'Geltungsbereich',
scopeTitle: 'Geltungsbereich dieser Hinweise',
scopeLeft:
'Diese Angaben gelten für diese Portfolio-Website und für Inhalte, die unmittelbar über diese Domain bereitgestellt werden. Für verlinkte externe Websites gelten die Hinweise der jeweiligen Betreiber.',
scopeRight:
'Die Website dient der Darstellung von Projekten, technischen Dienstleistungen und Kontaktmöglichkeiten im Bereich Softwareentwicklung, IT und digitale Systeme. Ein Vertragsabschluss erfolgt nicht automatisch über diese Website.',
privacyEyebrow: 'Datenschutz',
privacyTitle: 'Datenschutz & Datenverarbeitung',
privacyLeftParagraphs: [
'Verantwortlicher für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist {{firstname}} {{lastname}}, erreichbar unter {{contactMail}} sowie telefonisch unter {{contactPhone}}.',
'Diese Website verwendet nach aktuellem Stand kein eigenes Tracking, keine Analyse-Cookies, keine Marketing-Cookies und keine langfristigen persönlichen Nutzungsprofile.',
'Beim Aufruf der Website können technisch notwendige Server- und Verbindungsdaten verarbeitet werden, insbesondere IP-Adresse, Datum und Uhrzeit des Zugriffs, angeforderte Ressource, übertragene Datenmenge, Browser- und Betriebssysteminformationen sowie Referrer, soweit diese Daten vom Browser übermittelt werden.',
],
privacyRightParagraphs: [
'Die Verarbeitung erfolgt zur Bereitstellung der Website, zur Sicherstellung der technischen Stabilität und Sicherheit, zur Missbrauchsprävention, zur Fehleranalyse sowie zur Bearbeitung von Kontaktanfragen. Rechtsgrundlagen sind insbesondere Art. 6 Abs. 1 lit. f DSGVO und, bei vorvertraglicher Kommunikation, Art. 6 Abs. 1 lit. b DSGVO.',
'Wenn du per E-Mail oder Telefon Kontakt aufnimmst, werden die von dir übermittelten Angaben zur Bearbeitung der Anfrage und zur weiteren Kommunikation verarbeitet. Eine Weitergabe personenbezogener Daten erfolgt nur, wenn sie gesetzlich erforderlich ist, zur Rechtsdurchsetzung benötigt wird oder von dir veranlasst wurde.',
'Personenbezogene Daten werden nur so lange gespeichert, wie es für die genannten Zwecke, gesetzliche Aufbewahrungspflichten oder die Abwehr und Durchsetzung rechtlicher Ansprüche erforderlich ist.',
],
rightsEyebrow: 'Rechte',
rightsTitle: 'Betroffenenrechte & Beschwerden',
rightsLeft:
'Betroffene Personen haben im Rahmen der gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch. Soweit eine Verarbeitung auf Einwilligung beruht, kann diese Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen werden.',
rightsRight:
'Beschwerden können bei der österreichischen Datenschutzbehörde eingebracht werden: Barichgasse 40-42, 1030 Wien, E-Mail dsb@dsb.gv.at, Website dsb.gv.at.',
copyrightEyebrow: 'Urheberrecht',
copyrightTitle: 'Urheberrecht, externe Links & anwendbares Recht',
copyrightLeftParagraphs: [
'Alle Inhalte dieser Website, einschließlich Texte, Bilder, Grafiken, Fotos, Layouts, Quellcode und sonstige Werke, sind urheberrechtlich geschützt, sofern nicht anders angegeben.',
'Jede Nutzung außerhalb der gesetzlichen Schranken des Urheberrechts benötigt eine vorherige schriftliche Zustimmung des jeweiligen Rechteinhabers.',
],
copyrightRightParagraphs: [
'Diese Website kann Links zu externen Websites enthalten. Für deren Inhalte, Datenschutzpraxis und technische Sicherheit sind ausschließlich die jeweiligen Betreiber verantwortlich.',
'Es gilt ausschließlich österreichisches Recht, sofern keine zwingenden Verbraucherschutzbestimmungen etwas anderes vorgeben.',
],
},
bioTextsList: [
{ label: 'Sprachen', value: 'C#' },
{ label: 'Sprachen', value: 'TypeScript' },
{ label: 'Sprachen', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Datenbank', value: 'Microsoft SQL' },
{ label: 'Datenbank', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
{ label: 'Deklarative Sprachen', value: 'XAML' },
{ label: 'Deklarative Sprachen', value: 'YAML' },
{ label: 'Deklarative Sprachen', value: 'Markdown' },
],
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',
},
{
image: 'assets/optimized/portrait/culture.webp',
imageSrcset:
'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-384.webp 384w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 482,
text: 'Kultur',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'Ich arbeite hauptsächlich für das wiener Unternehmen namens SobIT GmbH. Die Firma ist ein Tochter-Unternehmen des Wiener Sozialdienste. Wir entwickeln Software für den Gesundheitsbereich. - Entwicklung moderner Desktop-, Mobile- und Webanwendungen zur Unterstützung der Mobilen Pflege.',
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
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.',
skills: ['TypeScript', 'MariaDB', 'YML'],
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.',
skills: ['TypeScript', 'Angular', 'FFmpeg', 'YML'],
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://github.com/FrauJulian/Discord-Audio-Stream',
description:
'Eine TypeScript-Library für Discord, die Audiowiedergabe einfacher macht, mit Fokus auf stabile 24/7-Streams ohne Unterbrechungen.',
skills: ['TypeScript', 'YML'],
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. 😁',
skills: ['TypeScript', 'Angular', 'YML'],
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
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.',
skills: ['TypeScript', 'MariaDB', 'Angular', 'YML'],
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,
},
],
};
+262
View File
@@ -0,0 +1,262 @@
import type { LanguagePack } from './language.types';
export const enLanguage: LanguagePack = {
app: {
selectorTitle: 'Select language',
changeLanguage: 'Language',
changeLanguageAriaLabel: 'Change language',
closeSelector: 'Close language selection',
languageEnglish: 'English',
languageGerman: 'German',
},
footer: {
noscriptMessage: 'Please enable JavaScript for the full experience.',
imprintLink: 'Imprint',
},
home: {
portfolioEyebrow: 'Portfolio',
heroIntro:
'I build reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.',
contactTitle: 'Contact me',
openPgpTitle: 'OpenPGP',
openPgpDescription: 'Download my public key for encrypted communication.',
openPgpDownload: 'Download public key',
openPgpFingerprintLabel: 'Fingerprint',
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 Austrias 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',
projectLanguagesAriaLabel: 'Project languages',
fallbackBioLabel: 'my stack',
},
imprint: {
legalEyebrow: 'Legal',
title: 'Imprint & Privacy',
intro:
'Legal information under Austrian law and privacy information under the GDPR for this website operated by {{firstname}} {{lastname}}, trading as Lechner Systems.',
backToPortfolio: 'Back to portfolio',
provider: 'Provider',
directContact: 'Direct Contact',
phone: 'Phone',
contact: 'Email',
abuse: 'Abuse Email',
scopeEyebrow: 'Scope',
scopeTitle: 'Scope Of These Notices',
scopeLeft:
'These notices apply to this portfolio website and to content made available directly through this domain. Linked third-party websites are subject to the notices of their respective operators.',
scopeRight:
'The website presents projects, technical services, and contact options in the field of software engineering, IT, and digital systems. No contract is concluded automatically through this website.',
privacyEyebrow: 'Privacy',
privacyTitle: 'Privacy And Data Processing',
privacyLeftParagraphs: [
'The controller for data processing within the meaning of the General Data Protection Regulation (GDPR) is {{firstname}} {{lastname}}, reachable at {{contactMail}} and by phone at {{contactPhone}}.',
'As currently implemented, this website does not use its own tracking, analytics cookies, marketing cookies, or long-term personal usage profiling.',
'When the website is accessed, technically necessary server and connection data may be processed, especially IP address, access date and time, requested resource, transferred data volume, browser and operating system information, and referrer where this data is transmitted by the browser.',
],
privacyRightParagraphs: [
'Processing takes place to provide the website, maintain technical stability and security, prevent abuse, analyze errors, and handle contact requests. The legal bases include Art. 6(1)(f) GDPR and, for pre-contractual communication, Art. 6(1)(b) GDPR.',
'If you contact me by email or phone, the information you provide is processed to handle your request and continue communication. Personal data is disclosed only where legally required, needed for legal claims, or initiated by you.',
'Personal data is stored only as long as necessary for the stated purposes, statutory retention duties, or the establishment, exercise, or defense of legal claims.',
],
rightsEyebrow: 'Rights',
rightsTitle: 'Data Subject Rights And Complaints',
rightsLeft:
'Subject to statutory requirements, data subjects have rights of access, rectification, erasure, restriction of processing, data portability, and objection. Where processing is based on consent, that consent may be withdrawn at any time with effect for the future.',
rightsRight:
'Complaints can be submitted to the Austrian Data Protection Authority: Barichgasse 40-42, 1030 Vienna, email dsb@dsb.gv.at, website dsb.gv.at.',
copyrightEyebrow: 'Copyright',
copyrightTitle: 'Copyright, External Links, And Applicable Law',
copyrightLeftParagraphs: [
'All content on this website, including texts, images, graphics, photographs, layouts, source code, and other works, is protected by copyright unless stated otherwise.',
'Any use beyond statutory exceptions requires prior written consent of the respective rights holder.',
],
copyrightRightParagraphs: [
'This website may contain links to third-party websites. Their operators are solely responsible for their contents, privacy practices, and technical security.',
'Austrian law applies exclusively, unless mandatory consumer protection provisions require otherwise.',
],
},
bioTextsList: [
{ label: 'Languages', value: 'C#' },
{ label: 'Languages', value: 'TypeScript' },
{ label: 'Languages', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Database', value: 'Microsoft SQL' },
{ label: 'Database', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
{ label: 'Declarative Languages', value: 'XAML' },
{ label: 'Declarative Languages', value: 'YAML' },
{ label: 'Declarative Languages', value: 'Markdown' },
],
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',
},
{
image: 'assets/optimized/portrait/culture.webp',
imageSrcset:
'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-384.webp 384w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px',
imageWidth: 640,
imageHeight: 482,
text: 'Culture',
},
],
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.',
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
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.',
skills: ['TypeScript', 'MariaDB', 'YML'],
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.',
skills: ['TypeScript', 'Angular', 'FFmpeg', 'YML'],
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://github.com/FrauJulian/Discord-Audio-Stream',
description:
'A TypeScript library for Discord that simplifies audio playback, with a focus on stable 24/7 streaming without interruptions.',
skills: ['TypeScript', 'YML'],
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 youre currently viewing is one of my actively maintained projects. 😁',
skills: ['TypeScript', 'Angular', 'YML'],
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
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.',
skills: ['TypeScript', 'MariaDB', 'Angular', 'YML'],
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,
},
],
};
+101
View File
@@ -0,0 +1,101 @@
export type LanguageCode = 'en' | 'de';
export interface LanguageProject {
title: string;
link: string;
description: string;
skills: string[];
icon?: string;
iconSrcset?: string;
iconSizes?: string;
iconWidth?: number;
iconHeight?: number;
circleIcon?: boolean;
}
export interface LanguageBioTextEntry {
label: string;
value: string;
}
export interface LanguagePortraitHighlight {
image: string;
imageSrcset: string;
imageSizes: string;
imageWidth: number;
imageHeight: number;
text: string;
}
export interface LanguagePack {
app: {
selectorTitle: string;
changeLanguage: string;
changeLanguageAriaLabel: string;
closeSelector: string;
languageEnglish: string;
languageGerman: string;
};
footer: {
noscriptMessage: string;
imprintLink: string;
};
home: {
portfolioEyebrow: string;
heroIntro: string;
contactTitle: string;
openPgpTitle: string;
openPgpDescription: string;
openPgpDownload: string;
openPgpFingerprintLabel: 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;
projectLanguagesAriaLabel: string;
fallbackBioLabel: string;
};
imprint: {
legalEyebrow: string;
title: string;
intro: string;
backToPortfolio: string;
provider: string;
directContact: string;
phone: string;
contact: string;
abuse: string;
scopeEyebrow: string;
scopeTitle: string;
scopeLeft: string;
scopeRight: string;
privacyEyebrow: string;
privacyTitle: string;
privacyLeftParagraphs: string[];
privacyRightParagraphs: string[];
rightsEyebrow: string;
rightsTitle: string;
rightsLeft: string;
rightsRight: string;
copyrightEyebrow: string;
copyrightTitle: string;
copyrightLeftParagraphs: string[];
copyrightRightParagraphs: string[];
};
bioTextsList: LanguageBioTextEntry[];
portraitHighlights: LanguagePortraitHighlight[];
projects: LanguageProject[];
}
export type LanguageShellPack = Pick<LanguagePack, 'app' | 'footer'>;
+31
View File
@@ -0,0 +1,31 @@
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: {
noscriptMessage: 'Please enable JavaScript for the full experience.',
imprintLink: 'Imprint',
},
};
export const deShellLanguage: LanguageShellPack = {
app: {
selectorTitle: 'Sprache auswählen',
changeLanguage: 'Sprache',
changeLanguageAriaLabel: 'Sprache ändern',
closeSelector: 'Sprachauswahl schließen',
languageEnglish: 'Englisch',
languageGerman: 'Deutsch',
},
footer: {
noscriptMessage: 'Bitte aktiviere JavaScript um alles zu sehen.',
imprintLink: 'Impressum',
},
};
-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;
+9
View File
@@ -0,0 +1,9 @@
<?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-04-17</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+1511 -436
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"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["src/**/*.spec.ts", "src/test.ts"]
}
+11 -5
View File
@@ -3,7 +3,6 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
@@ -11,17 +10,24 @@
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

Some files were not shown because too many files have changed in this diff Show More