Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24862a33d0 | ||
|
|
d8d1528fa9 | ||
|
|
9223fc3a12 | ||
|
|
6f9cf076cb | ||
|
|
9cc7249278 | ||
|
|
13693165df | ||
|
|
5a22e7f614 | ||
|
|
9069f4f6fc | ||
|
|
3bc31ec45f | ||
|
|
afcee10431 | ||
|
|
30c2ac0273 | ||
|
|
806c04e47a | ||
|
|
b598779c60 | ||
|
|
1ce29e47a5 | ||
|
|
2692c3c544 | ||
|
|
1a41621d0f | ||
|
|
3d81db7d53 | ||
|
|
3efece9e29 | ||
|
|
3f51d3c505 | ||
|
|
1e2b1efc66 | ||
|
|
07000e539c | ||
|
|
c6eb4fd452 | ||
|
|
c7ccbfe6c7 | ||
|
|
735b5fc206 | ||
|
|
d5d995c50d | ||
|
|
3b30030500 | ||
|
|
22f50266fe | ||
|
|
61b5de597f | ||
|
|
e6ad86e924 | ||
|
|
254cf50b1a | ||
|
|
3fadc5e0e7 | ||
|
|
2938b920c5 | ||
|
|
79b585f6c5 | ||
|
|
68ff51f8f4 | ||
|
|
27c196d652 | ||
|
|
f7e9a0650b | ||
|
|
0d7b8a59a5 | ||
|
|
38d4f7b2a6 | ||
|
|
4ada33b011 | ||
|
|
026af41c4d | ||
|
|
87fe28cbfc | ||
|
|
0160c2aec6 | ||
|
|
65e5249512 | ||
|
|
ef10c2efe7 | ||
|
|
f516438de3 | ||
|
|
108a686d7c | ||
|
|
500329d88a | ||
|
|
59ae95b874 | ||
|
|
c52856aaba | ||
|
|
cc39abed41 | ||
|
|
f0bf2e20ce | ||
|
|
c899b53cca | ||
|
|
e87c26d4c9 | ||
|
|
5fb4511b28 | ||
|
|
87546628d4 | ||
|
|
857a35e8f8 | ||
|
|
688dadf96d | ||
|
|
869c9b999f | ||
|
|
8838d360d3 | ||
|
|
f7f1cc2070 | ||
|
|
a33ba3e304 | ||
|
|
a2a77d01a6 | ||
|
|
7cb841072d | ||
|
|
38f391d525 | ||
|
|
7a967e4d55 | ||
|
|
0888795c09 | ||
|
|
7bbcdfa1b9 | ||
|
|
4cb3998a00 | ||
|
|
eef9c65840 | ||
|
|
7a57197158 | ||
|
|
d34b2aa669 |
@@ -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.*
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
IMAGE_TAG=latest
|
||||
MAIN_PORT=3000
|
||||
NG_ALLOWED_HOSTS=fraujulian.xyz,www.fraujulian.xyz,test.fraujulian.xyz
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
npm run fix || exit 1
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
npm run check || exit 1
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -1,12 +1,16 @@
|
||||
# 🖼️ FrauJulian's Portfolio Website
|
||||
|
||||
### [🙋♂️ Website Preview](https://fraujulian.xyz/)
|
||||

|
||||
|
||||
## 👂 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! ⛰️
|
||||
|
||||
@@ -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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"Server": {
|
||||
"Port": 3004
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 873 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 264 KiB |
@@ -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);
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = [
|
||||
['Ä', 'Ä'],
|
||||
['Ö', 'Ö'],
|
||||
['Ü', 'Ü'],
|
||||
['ä', 'ä'],
|
||||
['ö', 'ö'],
|
||||
['ü', 'ü'],
|
||||
['ß', 'ß'],
|
||||
['’', '’'],
|
||||
['“', '“'],
|
||||
['â€', '”'],
|
||||
['–', '–'],
|
||||
['—', '—'],
|
||||
['…', '…'],
|
||||
['§', '§'],
|
||||
['Ä', 'Ä'],
|
||||
['Ö', 'Ö'],
|
||||
['Ü', 'Ü'],
|
||||
['ä', 'ä'],
|
||||
['ö', 'ö'],
|
||||
['ü', 'ü'],
|
||||
['ß', 'ß'],
|
||||
['§', '§'],
|
||||
];
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { LanguageCode } from '../languages/language.types';
|
||||
|
||||
export interface LanguageOption {
|
||||
code: LanguageCode;
|
||||
accent: string;
|
||||
}
|
||||
@@ -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> | © 2023
|
||||
- {{ currentYear }} {{ globalFields.firstname }} {{ globalFields.lastname }} - All rights reserved.
|
||||
</p>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<p>© 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
|
||||
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 I’m fully immersed in planning, programming, and delivering custom solutions upon request.
|
||||
<br/>
|
||||
<br/>
|
||||
<a [href]="contactSafeMail">
|
||||
<b><u><h3>Just contact me!</h3></u></b>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<br>
|
||||
|
||||
<div class="bio grid-container">
|
||||
<div class="grid-item-start">
|
||||
<h1>I work with</h1>
|
||||
</div>
|
||||
<div class="grid-item" *ngFor="let bioText of bioTextsList; let i = index" [hidden]="i !== currentIndex">
|
||||
<h1>{{ bioText }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-area">
|
||||
<a [href]="contactSafeMail">
|
||||
<fa-icon class="fab" [icon]="faEnvelope" size="3x"></fa-icon>
|
||||
</a>
|
||||
|
||||
<a href="tel:{{globalFields.hrefContactTel}}">
|
||||
<fa-icon class="fab" [icon]="faPhone" size="3x"></fa-icon>
|
||||
</a>
|
||||
|
||||
<a href="https://discord.com/users/860206216893693973">
|
||||
<fa-icon class="fab" [icon]="faDiscord" size="3x"></fa-icon>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/fraujulian">
|
||||
<fa-icon class="fab" [icon]="faGithub" size="3x"></fa-icon>
|
||||
</a>
|
||||
|
||||
<a href="https://www.xing.com/profile/Julian_Lechner03274">
|
||||
<fa-icon class="fab" [icon]="faXing" size="3x"></fa-icon>
|
||||
</a>
|
||||
|
||||
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/">
|
||||
<fa-icon class="fab" [icon]="faLinkedin" size="3x"></fa-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="github_projects">
|
||||
<div *ngFor="let project of projects">
|
||||
<div class="container" [class.clickable]="project.Clickable" [class.disabled]="!project.Clickable"
|
||||
[style.cursor]="project.Clickable ? 'pointer' : 'default'"
|
||||
(click)="project.Clickable && (project.IsReadmeShown = true)">
|
||||
<div class="card">
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<a href="{{project.Link}}">
|
||||
<u><strong class="d-block text-gray-dark">{{ project.Title }}</strong></u>
|
||||
</a>
|
||||
<div class="stars">
|
||||
{{ project.Languages.join(' | ') }}
|
||||
<ng-container *ngIf="project.Stars != null">
|
||||
<fa-icon [icon]="faStar"></fa-icon>
|
||||
{{ project.Stars }}
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<div class="hero-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>
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface SvgIcon {
|
||||
readonly viewBox: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export type SvgIconSize = 'base' | 'lg';
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 9.2 MiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 Austria’s lakes.',
|
||||
'I mainly work for SobIT GmbH in Vienna, alongside long term projects for companies, organizations, and private clients.',
|
||||
],
|
||||
getInTouch: 'Get in touch!',
|
||||
projectsEyebrow: 'Current Jobs & Projects',
|
||||
openProjectPrefix: 'Open project',
|
||||
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 you’re 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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'>;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://fraujulian.xyz/sitemap.xml
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/test.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||