Compare commits

...
Author SHA1 Message Date
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
96 changed files with 16352 additions and 5396 deletions
+33
View File
@@ -0,0 +1,33 @@
# 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
.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
+3
View File
@@ -0,0 +1,3 @@
IMAGE_TAG=latest
MAIN_PORT=3000
NG_ALLOWED_HOSTS=fraujulian.xyz,www.fraujulian.xyz,test.fraujulian.xyz
+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 }}
+63
View File
@@ -0,0 +1,63 @@
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
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$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 }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.docker_tag }}
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.value }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
+97
View File
@@ -0,0 +1,97 @@
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//+/-}
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$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 }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.docker_tag }}
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.value }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- 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
+33 -35
View File
@@ -1,42 +1,40 @@
# 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
/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 @@
# Paketmanager-Abhaengigkeiten werden nicht formatiert
/node_modules
# Lokale Tool-Caches
.npm-cache
.eslintcache
# Generierte Build- und Test-Ausgaben
/dist
/coverage
# 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.*
backend/.env
backend/.env.*
# Angular-CLI-Cache
/.angular
# Generierte Tailwind-Ausgabe wird aus dem Quell-CSS erzeugt
src/assets/Resources/CSS/Tailwind.css
+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
}
}
]
}
+208
View File
@@ -0,0 +1,208 @@
# Repository Instructions
These instructions apply to this repository unless a more specific `AGENTS.md`
exists in a subdirectory.
## Required Verification
- Always verify changes with:
- `npm run check`
- `npm run test`
- If verification fails, `npm run fix` may help, but do not assume it resolves
every issue.
- Respect the existing `tsconfig`, ESLint, and Prettier configurations.
## Assets
- Do not edit generated optimized assets by hand.
- Update source assets in `src/assets/unoptimized/`.
- Regenerate optimized assets through the provided scripts, usually via
`npm run generate:assets` or commands that already include asset generation.
## Localization
- Keep localized text synchronized between:
- `src/languages/de.ts`
- `src/languages/en.ts`
- When changing user-facing text, update both language files unless the change
is intentionally language-specific.
## Architecture
- Create components with exactly one clear responsibility.
- Keep components focused on UI, user interaction, and view state.
- Move business logic into focused services.
- Structure code by feature or domain where appropriate.
- Put reusable generic elements in `shared/`.
- Put domain-specific logic and components in the relevant feature folder.
- Separate container and presentational components when a view becomes complex.
- Avoid cyclic dependencies between modules, services, and components.
- Add abstractions only when at least two real use cases exist.
- Keep services focused on one business or technical responsibility.
## TypeScript
- Keep strict TypeScript enabled.
- Use explicit types for public methods, service APIs, and complex return
values.
- Prefer `unknown` over `any` when a value still needs validation.
- Use `any` only when technically necessary and document the reason.
- Use union types for fixed value ranges.
- Use `readonly` for data that should not be mutated.
- Prefer `const` over `let` when reassignment is not needed.
- Check `null` and `undefined` explicitly before accessing nested values.
- Separate API DTOs from internal UI or domain models.
- Map external API data deliberately into internal models.
- Use clear names for interfaces, types, classes, variables, inputs, and
outputs.
- Avoid type casts when clean type narrowing is possible.
- Do not suppress type errors with `as unknown as`.
- Do not use non-null assertions to hide real nullability problems.
## Angular Components
- Use `OnPush` change detection unless there is a clear reason not to.
- Use inputs for externally provided data.
- Use outputs for events emitted to parent components.
- Use required inputs when a component cannot work without the data.
- Keep component classes small and readable.
- Extract repeated UI blocks into dedicated components.
- Use lifecycle hooks only when they are necessary.
- Keep constructors limited to dependency injection.
- Initialize data clearly and predictably.
- Do not make god components.
- Do not mix API access, mapping, business logic, and UI logic in one component.
- Prefer composition over component inheritance.
## Angular State
- Use Signals for local synchronous component state.
- Use RxJS for asynchronous data streams and complex event chains.
- Use services for shared state across multiple components.
- Keep state as close as possible to where it is needed.
- Avoid global state when local state is enough.
- Make state changes explicit and understandable.
- Avoid hidden state mutation across multiple services.
- Do not store derived values redundantly when they can be computed.
## RxJS
- Name Observables with a `$` suffix.
- Use the `async` pipe when a stream is displayed directly in a template.
- Use `switchMap` for requests where older requests should be canceled.
- Use `concatMap` when ordering must be guaranteed.
- Use `mergeMap` when parallel processing is intended.
- Use `exhaustMap` when new events should be ignored while work is running.
- Use `combineLatest` for dependent live values.
- Use `forkJoin` for multiple one-time requests that complete together.
- Clean up manual subscriptions with `takeUntilDestroyed` or an equivalent.
- Handle errors with `catchError` at the appropriate level.
- Return controlled fallback state when useful.
- Do not hide errors that matter for debugging or UX.
- Do not use `subscribe()` inside `subscribe()`.
- Do not use RxJS for trivial synchronous state.
- Avoid duplicate identical requests from independent subscriptions.
## HTTP and API
- Encapsulate all HTTP calls in services.
- Define response types for API responses.
- Use central API configuration through environments or injection tokens.
- Use interceptors for repeated technical concerns such as auth, headers, and
error handling.
- Implement loading, error, and empty states where requests affect UI.
- Treat API data defensively when quality is uncertain.
- Validate or normalize external data before broad UI usage.
- Avoid duplicate API calls through shared streams or caching where appropriate.
- Do not hardcode API URLs, tokens, roles, or secrets in components.
## Templates
- Keep templates declarative and easy to read.
- Use templates for presentation and simple bindings only.
- Move complex conditions into component code or computed signals.
- Use `@if` for conditional rendering.
- Use `@for` with `track` for lists.
- Track with a stable unique ID when available.
- Keep binding expressions short.
- Do not put business rules or complex ternaries into templates.
- Do not call expensive methods directly from templates.
- Split large templates into smaller components.
## Forms
- Use Reactive Forms for complex forms.
- Define validators explicitly.
- Show validation errors clearly in the UI.
- Keep form models and API DTOs separate when they differ.
- Map form data deliberately before submitting.
- Disable submit while requests are running when duplicate submits are a risk.
- Surface server-side validation errors in the form.
## Styling
- Prefer component-scoped styling.
- Use global styles only for truly global rules.
- Use a consistent design system or consistent utility classes.
- Reuse classes or components for repeated UI patterns.
- Account for responsive design in every new view.
- Prefer semantic HTML before complex CSS.
- Keep spacing, colors, and font sizes consistent.
- Use inline styles only for dynamic exceptions.
- Do not use `::ng-deep` as a default solution.
- Do not create global CSS rules that unintentionally affect other components.
- Avoid 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 frontend code.
- Implement roles and permissions server-side.
- Use frontend role checks only for UX, not as a security boundary.
- Render API-provided HTML only after controlled sanitization.
- Use `bypassSecurityTrustHtml` only after explicit security review.
- Validate redirect URLs against an allowlist.
- Protect auth flows against XSS and CSRF risks.
- Store tokens only with a deliberate security concept.
## Performance
- Use lazy loading for larger features.
- Use `OnPush` to reduce unnecessary change detection.
- Use `track` in lists.
- Use virtual scrolling for large lists.
- Avoid expensive computations in templates.
- Move expensive computations into computed signals, memoization, or services.
- Avoid large dependencies for small helper functions.
- Check bundle size impact before adding libraries.
- Optimize images and assets.
- Measure performance issues before adding complex optimizations.
## Testing
- Test services that contain business logic.
- Test complex component logic.
- Test critical user flows with E2E tests where appropriate.
- Mock external dependencies in unit tests.
- Avoid real HTTP calls in unit tests.
- Write tests with clear behavioral assertions.
- Cover edge cases and error paths.
- Keep tests independent from internal implementation details.
- Use test names that describe behavior.
- Do not write tests only for coverage.
- Do not use snapshots as a substitute for concrete assertions.
## Code Quality
- Use ESLint and Prettier.
- Keep naming consistent.
- Remove unused code and unused imports.
- Do not leave commented-out code blocks.
- Keep changes focused.
- Write commits with concrete messages.
- Use comments only for context that is not obvious from the code.
- Keep files small enough to understand quickly.
- Follow existing project conventions.
- Do not leave TODOs without context, a ticket, or a documented decision.
- Do not add dependencies without checking value, risk, and bundle cost.
+32
View File
@@ -0,0 +1,32 @@
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 prod-deps
WORKDIR /app
ENV HUSKY=0
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
FROM node:24.14.0-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server/server.mjs"]
+28 -20
View File
@@ -1,12 +1,16 @@
# 🖼️ FrauJulian's Portfolio Website
### [🙋‍♂️ Website Preview](https://fraujulian.xyz/)
![grafik](https://github.com/user-attachments/assets/65f1e491-62ab-4b16-a39c-5f04377ce5cb)
## 👂 Languages/Framework/Packages:
- Angular v19
<img width="2551" height="1175" alt="image" src="https://github.com/user-attachments/assets/24657c0a-4f95-4573-8472-092984d6cc4e" />
## 👂 Languages / Framework / Packages
- Angular v21 (with SSR)
- TypeScript
- SCSS
- Angular Material
- FontAwesome
## 💻 Development
@@ -19,42 +23,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! ⛰️
+47 -17
View File
@@ -20,21 +20,21 @@
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"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,
@@ -49,12 +49,15 @@
"styles": {
"minify": true,
"inlineCritical": true
},
"fonts": {
"inline": true
}
},
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": false,
"extractLicenses": true,
"aot": true,
"budgets": [
{
@@ -81,7 +84,7 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"port": 4200
"port": 3000
},
"configurations": {
"production": {
@@ -99,23 +102,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 +128,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
}
}
+14
View File
@@ -0,0 +1,14 @@
services:
portfolio:
image: docker.lechner-systems.at/lechnersystems/portfolio:${IMAGE_TAG}
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
dns:
- 192.168.100.2
- 192.168.100.6
ports:
- ${MAIN_PORT}: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];
+77
View File
@@ -0,0 +1,77 @@
Create a new custom location:
Location: ~*
Path: \.(?:js|css|mjs|webp|avif|png|jpg|jpeg|gif|svg|ico|woff2?)$
Scheme/Hostname/Port: same as Origin
Extra Options:
---
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:
---
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;
# Nur aktiv lassen, wenn dein NPMplus-Image brotli unterstützt.
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
+86 -42
View File
@@ -1,65 +1,109 @@
{
"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:assets": "npm run generate:static-files && npm run generate:optimized-assets",
"build": "npm run generate:assets && ng build --configuration production && node scripts/copy-static-root-files.cjs",
"------ STARTUP": "",
"test": "npm run generate:assets && ng test",
"dev": "npm run generate:assets && ng serve",
"start": "node dist/server/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);
+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,
};
+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 = '';
}
}
+5 -6
View File
@@ -1,12 +1,11 @@
import {mergeApplicationConfig, ApplicationConfig} from '@angular/core';
import {provideServerRendering} from '@angular/platform-server';
import { provideServerRendering } from '@angular/ssr';
import type { ApplicationConfig } from '@angular/core';
import { mergeApplicationConfig } from '@angular/core';
import {appConfig} from './app.config';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
]
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();
}
+146 -138
View File
@@ -1,146 +1,154 @@
<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 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();
}));
});
});
+352 -217
View File
@@ -1,251 +1,386 @@
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 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 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

-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>
+258
View File
@@ -0,0 +1,258 @@
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',
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,
},
],
};
+258
View File
@@ -0,0 +1,258 @@
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',
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,
},
],
};
+97
View File
@@ -0,0 +1,97 @@
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;
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',
},
};
+8 -5
View File
@@ -1,9 +1,12 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {ApplicationRef} from '@angular/core';
import type { BootstrapContext } from '@angular/platform-browser';
import { bootstrapApplication } from '@angular/platform-browser';
import type { ApplicationRef } from '@angular/core';
import {AppComponent} from './app/app.component';
import {config} from './app/app.config.server';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap: () => Promise<ApplicationRef> = (): Promise<ApplicationRef> => bootstrapApplication(AppComponent, config);
const bootstrap: (context: BootstrapContext) => Promise<ApplicationRef> = (
context: BootstrapContext,
): Promise<ApplicationRef> => bootstrapApplication(AppComponent, config, context);
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
+23 -16
View File
@@ -1,29 +1,35 @@
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 { APP_BASE_HREF } from '@angular/common';
import { CommonEngine, isMainModule } from '@angular/ssr/node';
import type { Express, NextFunction, Request, Response } from 'express';
import express 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 allowedHosts: readonly string[] = [
'fraujulian.xyz',
'www.fraujulian.xyz',
'test.fraujulian.xyz',
'localhost',
'127.0.0.1',
];
const app: Express = express();
const commonEngine: CommonEngine = new CommonEngine();
const commonEngine: CommonEngine = new CommonEngine({ allowedHosts });
app.get(
'**',
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: 'index.html'
index: 'index.html',
}),
);
app.get('**', (req: Request, res: Response, next: NextFunction): void => {
const {protocol, originalUrl, baseUrl, headers} = req;
app.get('/{*splat}', (req: Request, res: Response, next: NextFunction): void => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
@@ -31,16 +37,17 @@ app.get('**', (req: Request, res: Response, next: NextFunction): void => {
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{provide: APP_BASE_HREF, useValue: baseUrl}],
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html: string): Response => res.send(html))
.catch((err: Error): void => next(err.stack));
.catch((err: Error): void => next(err));
});
if (isMainModule(import.meta.url)) {
const port: number = ProductionConfig.Server.Port;
const port: number = 3000;
app.listen(port, (): void => {
console.log(`✔️ Listening on Port ${port}`);
console.log(`Listening on port ${port}`);
});
}
+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>
+1455 -448
View File
File diff suppressed because it is too large Load Diff
+4 -14
View File
@@ -1,19 +1,9 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [
"node"
]
"outDir": "./dist/out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/main.server.ts",
"src/server.ts"
],
"include": [
"src/**/*.d.ts"
]
"files": ["src/main.ts", "src/main.server.ts", "src/server.ts"],
"include": ["src/**/*.d.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"]
}
+9 -10
View File
@@ -1,27 +1,26 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"sourceMap": true,
"declaration": false,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
"module": "ES2022",
"lib": ["ES2022", "DOM"],
"types": []
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
},
"exclude": ["node_modules"]
}
+3 -10
View File
@@ -1,15 +1,8 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
"outDir": "./dist/out-tsc/spec",
"types": ["jasmine", "node"]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}