Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cc9c055c | ||
|
|
801d71ee26 | ||
|
|
1a47b37a70 | ||
|
|
84589156f6 | ||
|
|
8749ade6e1 | ||
|
|
1c8f76c81c | ||
|
|
20aaf4faa7 | ||
|
|
9ede5c02c1 | ||
|
|
c8af80cee3 | ||
|
|
88ae2f4f14 | ||
|
|
d5cbf3220a | ||
|
|
28b1b88ace | ||
|
|
614b331188 | ||
|
|
662ef5bcf9 | ||
|
|
f8640e7e54 | ||
|
|
6e76b8d199 | ||
|
|
afc9b875d2 | ||
|
|
584a5f95d3 | ||
|
|
f1abef50a9 | ||
|
|
1d8d88bc3d | ||
|
|
a84d0dae21 | ||
|
|
def1897723 | ||
|
|
2764ea0c96 | ||
|
|
ca1cf8aefe | ||
|
|
81613d701a | ||
|
|
2bebcd8527 | ||
|
|
6e0dd310ec | ||
|
|
dd07852c68 | ||
|
|
8ad3c8e01b | ||
|
|
5b23378a5a | ||
|
|
9c3921dabb | ||
|
|
d07db3d3cd | ||
|
|
0ae27adf90 | ||
|
|
4a43d55b64 | ||
|
|
46ec796b79 | ||
|
|
ba0663ee73 | ||
|
|
73747ad8d2 | ||
|
|
eeb80fc00c | ||
|
|
95375c4c75 | ||
|
|
df1438e136 | ||
|
|
63d68f6e14 | ||
|
|
aa2a2d409b | ||
|
|
5903c18657 | ||
|
|
a8b313dd16 | ||
|
|
e8bc2cd91e | ||
|
|
77cc73d659 | ||
|
|
afc445aa19 | ||
|
|
92a1cd803f | ||
|
|
9c61526f9b | ||
|
|
64dc8dc7cb | ||
|
|
942744556b | ||
|
|
cad41e5dc0 | ||
|
|
c99d086745 | ||
|
|
f0463229a1 | ||
|
|
17cb23aa40 |
No files matched your search
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# Git- und Projekt-Metadaten werden nicht in den Docker-Build-Kontext kopiert
|
# Git- und Projekt-Metadaten werden nicht in den Docker-Build-Kontext kopiert
|
||||||
.git
|
.git
|
||||||
.github
|
.gitea
|
||||||
.husky
|
.husky
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
.angular
|
.angular
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
public
|
||||||
.npm-cache
|
.npm-cache
|
||||||
coverage
|
coverage
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
MAIN_PORT=3000
|
MAIN_PORT=3000
|
||||||
NG_ALLOWED_HOSTS=fraujulian.xyz,www.fraujulian.xyz,test.fraujulian.xyz
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
description: Report a bug or unexpected behavior
|
about: Report a bug or unexpected behavior
|
||||||
title: '🐛 [BUG]: '
|
title: '🐛 [BUG]: '
|
||||||
|
ref: main
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: information
|
id: information
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Require one of the structured issue templates below.
|
||||||
|
blank_issues_enabled: false
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: 💡 Enhancement
|
name: 💡 Enhancement
|
||||||
description: Suggest a new feature or improvement
|
about: Suggest a new feature or improvement
|
||||||
title: '💡 [ENHANCEMENT]: '
|
title: '💡 [ENHANCEMENT]: '
|
||||||
|
ref: main
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: ⚙️ Improvement
|
name: ⚙️ Improvement
|
||||||
description: Suggest an improvement to existing functionality, code quality, or performance
|
about: Suggest an improvement to existing functionality, code quality, or performance
|
||||||
title: '⚙️ [Improvement]: '
|
title: '⚙️ [Improvement]: '
|
||||||
|
ref: main
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: context
|
id: context
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Publish portfolio
|
||||||
|
description: Build the image and create the matching Gitea release
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
image-tag:
|
||||||
|
description: Docker image tag
|
||||||
|
required: true
|
||||||
|
prerelease:
|
||||||
|
description: Whether the Gitea release is a pre-release
|
||||||
|
required: true
|
||||||
|
registry-username:
|
||||||
|
description: Registry username
|
||||||
|
required: true
|
||||||
|
registry-password:
|
||||||
|
description: Registry password or token
|
||||||
|
required: true
|
||||||
|
gitea-token:
|
||||||
|
description: Token for Gitea releases
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Use Node.js v24.15.0
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
|
||||||
|
- name: Set build version
|
||||||
|
shell: bash
|
||||||
|
run: npm run version:set
|
||||||
|
|
||||||
|
- name: Read version from package.json
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PRERELEASE: ${{ inputs.prerelease }}
|
||||||
|
run: |
|
||||||
|
version=$(node -p 'require("./package.json").version')
|
||||||
|
release_tag="v${version//+/-}"
|
||||||
|
if [ "$PRERELEASE" = true ]; then
|
||||||
|
release_tag="${release_tag}-dev"
|
||||||
|
fi
|
||||||
|
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.lechner-systems.at
|
||||||
|
username: ${{ inputs.registry-username }}
|
||||||
|
password: ${{ inputs.registry-password }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
VCS_REF=${{ steps.version.outputs.revision }}
|
||||||
|
tags: docker.lechner-systems.at/lechnersystems/portfolio:${{ inputs.image-tag }}
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ inputs.gitea-token }}
|
||||||
|
RELEASE_TAG: ${{ steps.version.outputs.release_tag }}
|
||||||
|
PRERELEASE: ${{ inputs.prerelease }}
|
||||||
|
run: |
|
||||||
|
commit_sha="$(git rev-parse HEAD)"
|
||||||
|
api_url="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases"
|
||||||
|
# ponytail: 50 recent releases cover normal reruns; paginate if old releases are rebuilt.
|
||||||
|
releases="$(curl --fail-with-body --silent --show-error \
|
||||||
|
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${api_url}?limit=50")"
|
||||||
|
previous_tag="$(printf '%s' "$releases" | jq -r \
|
||||||
|
--arg tag "$RELEASE_TAG" \
|
||||||
|
--argjson prerelease "$PRERELEASE" \
|
||||||
|
'[.[] | select(.draft == false and .prerelease == $prerelease and .tag_name != $tag)][0].tag_name // empty')"
|
||||||
|
|
||||||
|
if [ -n "$previous_tag" ]; then
|
||||||
|
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${commit_sha}")"
|
||||||
|
full_changelog="${{ gitea.server_url }}/${{ gitea.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
|
||||||
|
else
|
||||||
|
commit_messages="$(git log --reverse --format='- %s' "$commit_sha")"
|
||||||
|
full_changelog=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$commit_messages" ]; then
|
||||||
|
commit_messages='- No commits since the previous release'
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '## Changes'
|
||||||
|
echo
|
||||||
|
printf '%s\n' "$commit_messages"
|
||||||
|
echo
|
||||||
|
if [ -n "$full_changelog" ]; then
|
||||||
|
echo "**Full Changelog**: ${full_changelog}"
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
|
||||||
|
jq --null-input \
|
||||||
|
--arg tag "$RELEASE_TAG" \
|
||||||
|
--arg target "$commit_sha" \
|
||||||
|
--argjson prerelease "$PRERELEASE" \
|
||||||
|
--rawfile body release-notes.md \
|
||||||
|
'{tag_name: $tag, target_commitish: $target, name: $tag, body: $body, prerelease: $prerelease}' \
|
||||||
|
> release.json
|
||||||
|
|
||||||
|
release_id="$(printf '%s' "$releases" | jq -r \
|
||||||
|
--arg tag "$RELEASE_TAG" \
|
||||||
|
'.[] | select(.tag_name == $tag) | .id')"
|
||||||
|
if [ -n "$release_id" ]; then
|
||||||
|
method=PATCH
|
||||||
|
release_url="${api_url}/${release_id}"
|
||||||
|
else
|
||||||
|
method=POST
|
||||||
|
release_url="$api_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail-with-body --silent --show-error \
|
||||||
|
--request "$method" \
|
||||||
|
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data-binary @release.json \
|
||||||
|
"$release_url"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: CD Development
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 23 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.changes.outputs.changed }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check changes since previous development release
|
||||||
|
id: changes
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/actions/workflows/CD%20Development.yml/runs
|
||||||
|
run: |
|
||||||
|
runs="$(curl --fail-with-body --silent --show-error \
|
||||||
|
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API_URL}?branch=main&limit=50")"
|
||||||
|
previous_sha="$(printf '%s' "$runs" | jq -r \
|
||||||
|
--argjson current_run "$GITHUB_RUN_ID" \
|
||||||
|
'[.workflow_runs[] | select(.id != $current_run and .status == "success" and (.event == "schedule" or .event == "workflow_dispatch"))][0].head_sha // empty')"
|
||||||
|
if [ -z "$previous_sha" ]; then
|
||||||
|
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
git cat-file -e "${previous_sha}^{commit}"
|
||||||
|
if git diff --quiet "$previous_sha" HEAD; then
|
||||||
|
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||||
|
echo 'No changes since the previous successful development release.'
|
||||||
|
else
|
||||||
|
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.changed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish development image and pre-release
|
||||||
|
uses: ./.gitea/actions/publish
|
||||||
|
with:
|
||||||
|
image-tag: dev
|
||||||
|
prerelease: 'true'
|
||||||
|
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
|
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
gitea-token: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: CD Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish latest image and stable release
|
||||||
|
uses: ./.gitea/actions/publish
|
||||||
|
with:
|
||||||
|
image-tag: latest
|
||||||
|
prerelease: 'false'
|
||||||
|
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
|
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
gitea-token: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
formatting:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run format:check
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run version:check
|
||||||
|
- run: npm run text:check
|
||||||
|
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
||||||
patreon: # Replace with a single patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: fraujulian
|
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
||||||
polar: # Replace with a single Polar username
|
|
||||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
|
||||||
thanks_dev: # Replace with a single thanks dev username
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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,63 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
# Generierte Build- und Test-Ausgaben
|
# Generierte Build- und Test-Ausgaben
|
||||||
/dist
|
/dist
|
||||||
/coverage
|
/coverage
|
||||||
|
/public
|
||||||
/src/assets/optimized
|
/src/assets/optimized
|
||||||
|
|
||||||
# Betriebssystem-Dateileichen
|
# Betriebssystem-Dateileichen
|
||||||
|
|||||||
+21
-21
@@ -1,39 +1,39 @@
|
|||||||
# Paketmanager-Abhaengigkeiten werden nicht formatiert
|
# Dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# Lokale Tool-Caches
|
# Build, coverage, and generated output
|
||||||
.npm-cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Generierte Build- und Test-Ausgaben
|
|
||||||
/dist
|
/dist
|
||||||
/coverage
|
/coverage
|
||||||
|
/.angular
|
||||||
|
/public
|
||||||
|
src/assets/Resources/CSS/Tailwind.css
|
||||||
|
src/assets/optimized
|
||||||
|
|
||||||
# Betriebssystem-Dateileichen
|
# Package manager and tool caches
|
||||||
.DS_Store
|
.npm-cache
|
||||||
Thumbs.db
|
.eslintcache
|
||||||
|
.prettier-cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Editor- und IDE-Konfigurationen
|
# Logs and debug output
|
||||||
/.idea
|
*.log
|
||||||
/.vscode
|
|
||||||
|
|
||||||
# Lokale Log- und Debug-Dateien
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
*.log
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Lokale Secrets und Umgebungsdateien
|
# Local environment and secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/.env.*
|
backend/.env.*
|
||||||
|
|
||||||
# Angular-CLI-Cache
|
# Editor, IDE, and OS files
|
||||||
/.angular
|
/.idea
|
||||||
|
/.vscode
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Generierte Tailwind-Ausgabe wird aus dem Quell-CSS erzeugt
|
# Markdown is maintained manually and excluded from Prettier formatting
|
||||||
src/assets/Resources/CSS/Tailwind.css
|
*.md
|
||||||
@@ -1,208 +1,223 @@
|
|||||||
# Repository Instructions
|
# Repository Guidelines
|
||||||
|
|
||||||
These instructions apply to this repository unless a more specific `AGENTS.md`
|
## Project Context
|
||||||
exists in a subdirectory.
|
|
||||||
|
|
||||||
## Required Verification
|
This is an Angular portfolio application using Angular 22, TypeScript, SCSS,
|
||||||
|
Vitest tests, and static hosting for production output. Production
|
||||||
|
application code lives in `src/app`; the client entry point lives in
|
||||||
|
`src/main.ts`.
|
||||||
|
|
||||||
- Always verify changes with:
|
Feature views live in folders such as `src/app/home`, `src/app/footer`, and
|
||||||
- `npm run check`
|
`src/app/imprint`. Reusable generic UI building blocks belong in
|
||||||
- `npm run test`
|
`src/app/shared`; reusable services belong in `src/app/services`. Assets and
|
||||||
- If verification fails, `npm run fix` may help, but do not assume it resolves
|
language content live in `src/assets` and `src/languages`. Do not manually
|
||||||
every issue.
|
maintain generated or copied output in `public/assets/optimized` or `dist`.
|
||||||
- Respect the existing `tsconfig`, ESLint, and Prettier configurations.
|
|
||||||
|
|
||||||
## Assets
|
## Agent Workflow
|
||||||
|
|
||||||
- Do not edit generated optimized assets by hand.
|
- Read relevant project files before making changes and follow existing
|
||||||
- Update source assets in `src/assets/unoptimized/`.
|
patterns.
|
||||||
- Regenerate optimized assets through the provided scripts, usually via
|
- Always validate every change with `npm run check`.
|
||||||
`npm run generate:assets` or commands that already include asset generation.
|
- Do not run a build unless the user explicitly asks for it.
|
||||||
|
- If `npm run check` fails, read the cause first. `npm run fix` may be used, but
|
||||||
|
it cannot fix every issue.
|
||||||
|
- Always follow the active ESLint, Prettier, and `tsconfig` rules.
|
||||||
|
- Change only files that are relevant to the task.
|
||||||
|
- Do not revert unrelated or unclear local changes.
|
||||||
|
- Commit, push, or open PRs only when explicitly asked.
|
||||||
|
|
||||||
## Localization
|
## Important Commands
|
||||||
|
|
||||||
- Keep localized text synchronized between:
|
- `npm run dev`: generates assets and starts the Angular development server.
|
||||||
- `src/languages/de.ts`
|
- `npm run build`: generates assets, builds the production app, and copies
|
||||||
- `src/languages/en.ts`
|
static root files. Run this only when explicitly requested.
|
||||||
- When changing user-facing text, update both language files unless the change
|
- `npm start`: serves the built static app through `scripts/static-server.mjs`.
|
||||||
is intentionally language-specific.
|
- `npm test`: generates assets and starts Angular tests with Vitest.
|
||||||
|
- `npm run check`: runs linting, format checks, version checks, and source text
|
||||||
|
checks. Always use this as the final validation.
|
||||||
|
- `npm run fix`: places version data, runs ESLint fixes, fixes source text, and
|
||||||
|
formats the project.
|
||||||
|
|
||||||
## Architecture
|
Use Node `>=24.14.0` and npm `>=11.12.0`.
|
||||||
|
|
||||||
- Create components with exactly one clear responsibility.
|
## Code Style
|
||||||
- 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
|
- TypeScript strict mode is enabled. `strictTemplates` is enabled.
|
||||||
|
- Prettier is authoritative: 2 spaces, single quotes, semicolons, trailing
|
||||||
- Keep strict TypeScript enabled.
|
commas, and default print width 100.
|
||||||
- Use explicit types for public methods, service APIs, and complex return
|
- Markdown wraps at 80 characters.
|
||||||
values.
|
- Angular selectors use the `app` prefix.
|
||||||
- Prefer `unknown` over `any` when a value still needs validation.
|
- Components follow the pattern `feature.component.ts`,
|
||||||
- Use `any` only when technically necessary and document the reason.
|
`feature.component.html`, and `feature.component.spec.ts`.
|
||||||
- Use union types for fixed value ranges.
|
- Services follow the pattern `name.service.ts`.
|
||||||
- Use `readonly` for data that should not be mutated.
|
- Place shared types in `*.types.ts` files when that matches the local pattern.
|
||||||
- Prefer `const` over `let` when reassignment is not needed.
|
- Explicitly type public methods, service APIs, and complex return values.
|
||||||
- Check `null` and `undefined` explicitly before accessing nested values.
|
- Use `unknown` instead of `any` when a value must be checked first.
|
||||||
- Separate API DTOs from internal UI or domain models.
|
- Use `any` only when technically necessary, and document the reason.
|
||||||
- Map external API data deliberately into internal models.
|
- Use `const` instead of `let` when reassignment is not needed.
|
||||||
- Use clear names for interfaces, types, classes, variables, inputs, and
|
- Use `readonly` for data that should not be changed.
|
||||||
outputs.
|
- Do not use non-null assertions to hide real nullability issues.
|
||||||
- Avoid type casts when clean type narrowing is possible.
|
|
||||||
- Do not suppress type errors with `as unknown as`.
|
- Do not suppress type errors with `as unknown as`.
|
||||||
- Do not use non-null assertions to hide real nullability problems.
|
- Use union types for fixed value sets instead of magic strings.
|
||||||
|
- Keep API DTOs separate from internal UI or domain models.
|
||||||
|
|
||||||
|
## Angular Architecture
|
||||||
|
|
||||||
|
- Components must have exactly one clear responsibility.
|
||||||
|
- Keep components primarily responsible for UI, user interaction, and view state.
|
||||||
|
- Put business logic, HTTP access, mapping, and shared state into focused
|
||||||
|
services.
|
||||||
|
- Structure code by features or domains, not only by file types.
|
||||||
|
- Put domain-specific logic and domain-specific components in the relevant
|
||||||
|
feature folder.
|
||||||
|
- Put generic reusable elements in `shared/`.
|
||||||
|
- Split container components from presentational components when a view becomes
|
||||||
|
complex.
|
||||||
|
- Create abstractions only when at least two real use cases exist.
|
||||||
|
- Avoid cyclic dependencies between components, services, and modules.
|
||||||
|
- Do not use services as unordered global storage.
|
||||||
|
|
||||||
## Angular Components
|
## Angular Components
|
||||||
|
|
||||||
- Use `OnPush` change detection unless there is a clear reason not to.
|
- Use `ChangeDetectionStrategy.OnPush` unless there is a concrete reason not to.
|
||||||
- Use inputs for externally provided data.
|
- Use required inputs when a component cannot work meaningfully without the
|
||||||
- Use outputs for events emitted to parent components.
|
data.
|
||||||
- Use required inputs when a component cannot work without the data.
|
- Name inputs and outputs clearly after their purpose.
|
||||||
|
- Do not mutate input data directly.
|
||||||
- Keep component classes small and readable.
|
- Keep component classes small and readable.
|
||||||
- Extract repeated UI blocks into dedicated components.
|
- Extract repeated UI blocks into their own components.
|
||||||
- Use lifecycle hooks only when they are necessary.
|
- Use lifecycle hooks only when they are truly necessary.
|
||||||
- Keep constructors limited to dependency injection.
|
- Keep constructors free of logic; prefer dependency injection with `inject()`
|
||||||
- Initialize data clearly and predictably.
|
when it fits the existing code.
|
||||||
- Do not make god components.
|
- Do not manipulate the DOM directly when Angular APIs are sufficient.
|
||||||
- Do not mix API access, mapping, business logic, and UI logic in one component.
|
- Do not use component inheritance when composition is simpler.
|
||||||
- Prefer composition over component inheritance.
|
|
||||||
|
|
||||||
## Angular State
|
## State, RxJS, and Data Flows
|
||||||
|
|
||||||
- Use Signals for local synchronous component state.
|
- Use Signals for local synchronous component state.
|
||||||
|
- Use computed Signals for derived synchronous values.
|
||||||
- Use RxJS for asynchronous data streams and complex event chains.
|
- Use RxJS for asynchronous data streams and complex event chains.
|
||||||
- Use services for shared state across multiple components.
|
- Encapsulate shared state in services.
|
||||||
- Keep state as close as possible to where it is needed.
|
- Keep state as close as possible to where it is needed.
|
||||||
- Avoid global state when local state is enough.
|
- Make state changes explicit and easy to follow.
|
||||||
- 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.
|
- Name Observables with a `$` suffix.
|
||||||
- Use the `async` pipe when a stream is displayed directly in a template.
|
- Use the `async` pipe when a stream is displayed directly in the template.
|
||||||
- Use `switchMap` for requests where older requests should be canceled.
|
- Choose `switchMap`, `concatMap`, `mergeMap`, and `exhaustMap` deliberately
|
||||||
- Use `concatMap` when ordering must be guaranteed.
|
based on their semantics.
|
||||||
- Use `mergeMap` when parallel processing is intended.
|
- Use `combineLatest` for dependent live values and `forkJoin` for multiple
|
||||||
- Use `exhaustMap` when new events should be ignored while work is running.
|
one-time requests.
|
||||||
- Use `combineLatest` for dependent live values.
|
- End manual subscriptions with `takeUntilDestroyed` or an equivalent mechanism.
|
||||||
- Use `forkJoin` for multiple one-time requests that complete together.
|
- Do not write `subscribe()` inside `subscribe()`.
|
||||||
- Clean up manual subscriptions with `takeUntilDestroyed` or an equivalent.
|
- Handle errors with `catchError` in the right place and do not blindly swallow
|
||||||
- Handle errors with `catchError` at the appropriate level.
|
them with `EMPTY`.
|
||||||
- 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.
|
- Do not use RxJS for trivial synchronous state.
|
||||||
- Avoid duplicate identical requests from independent subscriptions.
|
|
||||||
|
|
||||||
## HTTP and API
|
## HTTP and API
|
||||||
|
|
||||||
- Encapsulate all HTTP calls in services.
|
- Encapsulate all HTTP calls in services.
|
||||||
- Define response types for API responses.
|
- Define response types for API responses.
|
||||||
- Use central API configuration through environments or injection tokens.
|
- Keep API configuration centralized through environments or injection tokens.
|
||||||
- Use interceptors for repeated technical concerns such as auth, headers, and
|
- Use interceptors for recurring technical tasks such as auth, headers, or error
|
||||||
error handling.
|
handling.
|
||||||
- Implement loading, error, and empty states where requests affect UI.
|
- Include loading, error, and empty states for slow or failed requests.
|
||||||
- Treat API data defensively when quality is uncertain.
|
- Treat external data defensively, and validate or normalize it before broad UI
|
||||||
- Validate or normalize external data before broad UI usage.
|
use.
|
||||||
- Avoid duplicate API calls through shared streams or caching where appropriate.
|
- Map external API data deliberately into internal models.
|
||||||
|
- Avoid duplicate API calls through shared stream or cache handling.
|
||||||
- Do not hardcode API URLs, tokens, roles, or secrets in components.
|
- Do not hardcode API URLs, tokens, roles, or secrets in components.
|
||||||
|
|
||||||
## Templates
|
## Templates
|
||||||
|
|
||||||
- Keep templates declarative and easy to read.
|
- Keep templates declarative and easy to read.
|
||||||
- Use templates for presentation and simple bindings only.
|
- Use templates only for presentation and simple bindings.
|
||||||
- Move complex conditions into component code or computed signals.
|
- Move complex conditions into component code or computed Signals.
|
||||||
- Use `@if` for conditional rendering.
|
- Use `@if` for conditional rendering.
|
||||||
- Use `@for` with `track` for lists.
|
- Always use `@for` with `track`.
|
||||||
- Track with a stable unique ID when available.
|
- Use stable unique IDs for `track`; use the index only when no stable ID
|
||||||
|
exists.
|
||||||
|
- Do not put business rules, complex ternaries, or expensive method calls in
|
||||||
|
templates.
|
||||||
|
- Use pipes only for pure formatting.
|
||||||
|
- Break large templates into smaller components.
|
||||||
- Keep binding expressions short.
|
- 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
|
## Forms
|
||||||
|
|
||||||
- Use Reactive Forms for complex forms.
|
- Use Reactive Forms for complex forms.
|
||||||
- Define validators explicitly.
|
- Define validators explicitly.
|
||||||
- Show validation errors clearly in the UI.
|
- Show validation errors clearly in the UI.
|
||||||
|
- Validate critical rules again on the backend.
|
||||||
- Keep form models and API DTOs separate when they differ.
|
- Keep form models and API DTOs separate when they differ.
|
||||||
- Map form data deliberately before submitting.
|
- Map form data deliberately before sending it.
|
||||||
- Disable submit while requests are running when duplicate submits are a risk.
|
- Disable submit during active requests when repeated submission is problematic.
|
||||||
- Surface server-side validation errors in the form.
|
- Show server errors visibly in the form.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
- Prefer component-scoped styling.
|
- Prefer component-scoped styling.
|
||||||
- Use global styles only for truly global rules.
|
- Use global styles in `src/styles.scss` only for truly global rules.
|
||||||
- Use a consistent design system or consistent utility classes.
|
- Use consistent design tokens, utility classes, or reusable components for
|
||||||
- Reuse classes or components for repeated UI patterns.
|
recurring UI patterns.
|
||||||
- Account for responsive design in every new view.
|
- Consider responsive design for every new view.
|
||||||
- Prefer semantic HTML before complex CSS.
|
- Use semantic HTML before adding complex CSS.
|
||||||
- Keep spacing, colors, and font sizes consistent.
|
- Keep spacing, colors, and font sizes consistent.
|
||||||
- Use inline styles only for dynamic exceptions.
|
- Use inline styles only for dynamic edge cases.
|
||||||
- Do not use `::ng-deep` as a default solution.
|
- Do not use `::ng-deep` as a default solution.
|
||||||
- Do not create global CSS rules that unintentionally affect other components.
|
- Do not write global CSS rules that unintentionally affect unrelated
|
||||||
- Avoid fixed pixel layouts without responsive behavior.
|
components.
|
||||||
|
- Do not build fixed-pixel layouts without responsive behavior.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Treat all external data as potentially unsafe.
|
- Treat all external data as potentially unsafe.
|
||||||
- Never trust user input without validation.
|
- Never trust user input without validation.
|
||||||
- Do not store secrets in frontend code.
|
- Do not store secrets in the frontend or repository.
|
||||||
- Implement roles and permissions server-side.
|
- Use `.env.example` as the documented template; keep real environment-specific
|
||||||
- Use frontend role checks only for UX, not as a security boundary.
|
values out of version control.
|
||||||
- Render API-provided HTML only after controlled sanitization.
|
- Enforce roles and permissions server-side; use frontend checks only for UX.
|
||||||
- Use `bypassSecurityTrustHtml` only after explicit security review.
|
- Render HTML from APIs only after controlled sanitization.
|
||||||
- Validate redirect URLs against an allowlist.
|
- Use `bypassSecurityTrustHtml` only after an explicit security review.
|
||||||
|
- Check redirect URLs against an allowlist.
|
||||||
- Protect auth flows against XSS and CSRF risks.
|
- Protect auth flows against XSS and CSRF risks.
|
||||||
- Store tokens only with a deliberate security concept.
|
- Store tokens only with a deliberate security concept.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- Use lazy loading for larger features.
|
- Lazy-load larger features.
|
||||||
- Use `OnPush` to reduce unnecessary change detection.
|
- Use `OnPush` to reduce unnecessary change detection.
|
||||||
- Use `track` in lists.
|
- Always render lists with stable `track` values.
|
||||||
- Use virtual scrolling for large lists.
|
- Consider virtual scrolling for large lists.
|
||||||
- Avoid expensive computations in templates.
|
- Do not run expensive calculations in templates.
|
||||||
- Move expensive computations into computed signals, memoization, or services.
|
- Move expensive calculations into computed Signals, memoization, or services.
|
||||||
- Avoid large dependencies for small helper functions.
|
- Do not add a large dependency for a small helper function.
|
||||||
- Check bundle size impact before adding libraries.
|
- Check value, risk, and bundle cost before adding a dependency.
|
||||||
- Optimize images and assets.
|
- Optimize images and assets.
|
||||||
- Measure performance issues before adding complex optimizations.
|
- Measure performance problems before implementing complex optimizations.
|
||||||
|
|
||||||
## Testing
|
## Tests
|
||||||
|
|
||||||
- Test services that contain business logic.
|
- Keep tests next to the code under test with `*.spec.ts`.
|
||||||
|
- Test services that contain domain logic.
|
||||||
- Test complex component logic.
|
- Test complex component logic.
|
||||||
- Test critical user flows with E2E tests where appropriate.
|
- Cover critical user flows with E2E tests when an E2E setup exists or is
|
||||||
|
requested.
|
||||||
- Mock external dependencies in unit tests.
|
- Mock external dependencies in unit tests.
|
||||||
- Avoid real HTTP calls in unit tests.
|
- Do not use real HTTP calls in unit tests.
|
||||||
- Write tests with clear behavioral assertions.
|
- Write tests with a clear domain-level assertion.
|
||||||
- Cover edge cases and error paths.
|
- Test edge cases and failure cases.
|
||||||
- Keep tests independent from internal implementation details.
|
- Do not couple tests unnecessarily to implementation details.
|
||||||
- Use test names that describe behavior.
|
- Test names should describe behavior rather than implementation.
|
||||||
- Do not write tests only for coverage.
|
|
||||||
- Do not use snapshots as a substitute for concrete assertions.
|
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality and Project Practice
|
||||||
|
|
||||||
- Use ESLint and Prettier.
|
|
||||||
- Keep naming consistent.
|
|
||||||
- Remove unused code and unused imports.
|
- Remove unused code and unused imports.
|
||||||
- Do not leave commented-out code blocks.
|
- Do not leave commented-out code blocks in the repository.
|
||||||
- Keep changes focused.
|
- Leave TODOs only with context, a ticket, or a documented decision.
|
||||||
- Write commits with concrete messages.
|
- Use comments sparingly and only for context that is not obvious from the code.
|
||||||
- Use comments only for context that is not obvious from the code.
|
|
||||||
- Keep files small enough to understand quickly.
|
- Keep files small enough to understand quickly.
|
||||||
- Follow existing project conventions.
|
- Follow existing project conventions.
|
||||||
- Do not leave TODOs without context, a ticket, or a documented decision.
|
- Prefer small, focused pull requests.
|
||||||
- Do not add dependencies without checking value, risk, and bundle cost.
|
- Write commit messages with a concrete statement, for example `fix:` or
|
||||||
|
`improve:` followed by a short summary.
|
||||||
|
- Do not write commits named only `fix`, `stuff`, `update`, or `changes`.
|
||||||
|
- Document workarounds when they are unavoidable.
|
||||||
+16
-11
@@ -1,4 +1,7 @@
|
|||||||
FROM node:24.14.0-bookworm-slim AS build
|
ARG VCS_REF=unknown
|
||||||
|
ARG APP_VERSION=unknown
|
||||||
|
|
||||||
|
FROM node:24.15.0-bookworm-slim AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV HUSKY=0
|
ENV HUSKY=0
|
||||||
@@ -9,24 +12,26 @@ RUN npm ci --no-audit --no-fund
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:24.14.0-bookworm-slim AS prod-deps
|
FROM node:24.15.0-bookworm-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
ARG VCS_REF
|
||||||
ENV HUSKY=0
|
ARG APP_VERSION
|
||||||
|
|
||||||
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
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
|
LABEL org.opencontainers.image.title="Personal Portfolio Website"
|
||||||
|
LABEL org.opencontainers.image.description="👋 My portfolio website built with Angular v22 — a concise introduction to who I am and what I build."
|
||||||
|
LABEL org.opencontainers.image.licenses="GPL-3.0-only"
|
||||||
|
LABEL org.opencontainers.image.version="${APP_VERSION}"
|
||||||
|
LABEL org.opencontainers.image.revision="${VCS_REF}"
|
||||||
|
LABEL org.opencontainers.image.source="https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website"
|
||||||
|
|
||||||
COPY --from=build --chown=node:node /app/dist ./dist
|
COPY --from=build --chown=node:node /app/dist ./dist
|
||||||
|
COPY --chown=node:node scripts/static-server.mjs ./scripts/static-server.mjs
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "dist/server/server.mjs"]
|
CMD ["node", "scripts/static-server.mjs"]
|
||||||
@@ -1,68 +1,75 @@
|
|||||||
# 🖼️ FrauJulian's Portfolio Website
|
# FrauJulian's Portfolio Website
|
||||||
|
|
||||||
### [🙋♂️ Website Preview](https://fraujulian.xyz/)
|
## Preview
|
||||||
|
|
||||||
<img width="2551" height="1175" alt="image" src="https://github.com/user-attachments/assets/24657c0a-4f95-4573-8472-092984d6cc4e" />
|
Visit the live website at [fraujulian.xyz](https://fraujulian.xyz/).
|
||||||
|
|
||||||
## 👂 Languages / Framework / Packages
|
## Technology
|
||||||
|
|
||||||
- Angular v21 (with SSR)
|
- Angular 22
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- SCSS
|
- SCSS
|
||||||
- Angular Material
|
- Vitest for tests
|
||||||
- FontAwesome
|
|
||||||
|
|
||||||
## 💻 Development
|
## Features
|
||||||
|
|
||||||
### Development Preview
|
- English and German language options (`?lang=de` selects German)
|
||||||
|
- About, work, and project portfolio
|
||||||
|
- Contact links
|
||||||
|
- Imprint and privacy information
|
||||||
|
- Interactive portrait highlights
|
||||||
|
|
||||||
|
## Project Architecture
|
||||||
|
|
||||||
|
- `src/app/home` — portfolio landing page
|
||||||
|
- `src/app/imprint` — imprint and privacy page
|
||||||
|
- `src/app/footer` — site footer
|
||||||
|
- `src/app/shared` — reusable UI components
|
||||||
|
- `src/app/services` — language and preference services
|
||||||
|
- `src/languages` — English and German content
|
||||||
|
- `scripts` — asset generation, versioning, and static file tools
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Requirements: Node.js 24.15 or newer and npm 11.12 or newer.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
The development server generates required static assets and runs the site at
|
||||||
|
`http://localhost:4200`.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/`
|
The build writes the static site to `dist/browser`. The static server serves the
|
||||||
directory. The production build optimizes your application for performance and
|
built site at `http://localhost:3000`.
|
||||||
speed.
|
|
||||||
|
|
||||||
### Start compiled Project
|
## Tests
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Code Quality
|
|
||||||
|
|
||||||
### Lint & Format check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto-fix lint & format issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Running Unit Tests
|
|
||||||
|
|
||||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test
|
|
||||||
runner, use the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Enjoy?
|
This generates assets and runs the Vitest tests in jsdom.
|
||||||
|
|
||||||
~ made by [**FrauJulian**](https://fraujulian.xyz/).
|
## Docs
|
||||||
Give it a star ⭐ on
|
|
||||||
[GitHub](https://github.com/FrauJulian/Personal-Portfolio-Website)!
|
|
||||||
|
|
||||||
### Greetings from Austria! ⛰️
|
- [Nginx Proxy Manager deployment notes](npm.md)
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
Run `npm run check` before submitting changes. See [AGENTS.md](AGENTS.md) for
|
||||||
|
project conventions.
|
||||||
|
|
||||||
|
## LICENSE
|
||||||
|
|
||||||
|
Licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||||
+42
-36
@@ -15,32 +15,41 @@
|
|||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist",
|
"outputPath": {
|
||||||
"index": "src/index.html",
|
"base": "dist",
|
||||||
|
"browser": "browser",
|
||||||
|
"media": "media"
|
||||||
|
},
|
||||||
|
"outputMode": "static",
|
||||||
|
"index": {
|
||||||
|
"input": "src/index.html",
|
||||||
|
"output": "index.html",
|
||||||
|
"preloadInitial": true
|
||||||
|
},
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
|
"server": false,
|
||||||
"polyfills": [],
|
"polyfills": [],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": ["src"]
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"autoCsp": true
|
||||||
|
},
|
||||||
|
"clearScreen": false,
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "src/assets/optimized",
|
"input": "src/assets/optimized",
|
||||||
"output": "assets/optimized"
|
"output": "assets/optimized"
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "public"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"server": "src/main.server.ts",
|
"serviceWorker": false
|
||||||
"prerender": true,
|
|
||||||
"ssr": {
|
|
||||||
"entry": "src/server.ts"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -48,13 +57,16 @@
|
|||||||
"scripts": true,
|
"scripts": true,
|
||||||
"styles": {
|
"styles": {
|
||||||
"minify": true,
|
"minify": true,
|
||||||
"inlineCritical": true
|
"inlineCritical": true,
|
||||||
|
"removeSpecialComments": true
|
||||||
},
|
},
|
||||||
"fonts": {
|
"fonts": {
|
||||||
"inline": true
|
"inline": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
|
"crossOrigin": "anonymous",
|
||||||
|
"subresourceIntegrity": true,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
"namedChunks": false,
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
@@ -69,22 +81,32 @@
|
|||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "4kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyScript",
|
||||||
|
"maximumWarning": "350kB",
|
||||||
|
"maximumError": "500kB"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true,
|
"sourceMap": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": true,
|
||||||
|
"vendor": false,
|
||||||
|
"sourcesContent": true
|
||||||
|
},
|
||||||
"namedChunks": true
|
"namedChunks": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"port": 3000
|
"port": 4200
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -97,30 +119,14 @@
|
|||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"buildTarget": "::development",
|
||||||
"browsers": "ChromeHeadless",
|
|
||||||
"polyfills": ["zone.js", "zone.js/testing"],
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"watch": false
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "src/assets/optimized",
|
|
||||||
"output": "assets/optimized"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "public",
|
|
||||||
"output": "public"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": ["src/styles.scss"],
|
|
||||||
"scripts": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-7
@@ -1,14 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
portfolio:
|
portfolio:
|
||||||
image: docker.lechner-systems.at/lechnersystems/portfolio:${IMAGE_TAG}
|
image: docker.lechner-systems.at/lechnersystems/portfolio:${IMAGE_TAG:-latest}
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
dns:
|
dns:
|
||||||
- 192.168.100.2
|
- 192.168.100.2
|
||||||
- 192.168.100.6
|
- 192.168.100.6
|
||||||
ports:
|
ports:
|
||||||
- ${MAIN_PORT}:3000
|
- '${MAIN_PORT:-3000}:3000'
|
||||||
+15
-11
@@ -1,12 +1,16 @@
|
|||||||
Create a new custom location:
|
# Nginx Proxy Manager Notes
|
||||||
|
|
||||||
Location: ~*
|
## Custom Location
|
||||||
Path: \.(?:js|css|mjs|webp|avif|png|jpg|jpeg|gif|svg|ico|woff2?)$
|
|
||||||
Scheme/Hostname/Port: same as Origin
|
|
||||||
Extra Options:
|
|
||||||
|
|
||||||
---
|
Create a new custom location with these values:
|
||||||
|
|
||||||
|
- Location: `~*`
|
||||||
|
- Path: `\.(?:js|css|mjs|webp|avif|png|jpg|jpeg|gif|svg|ico|woff2?)$`
|
||||||
|
- Scheme/Hostname/Port: `same as Origin`
|
||||||
|
|
||||||
|
Use the following extra options:
|
||||||
|
|
||||||
|
```nginx
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -17,13 +21,13 @@ proxy_buffering on;
|
|||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
add_header Vary "Accept-Encoding" always;
|
add_header Vary "Accept-Encoding" always;
|
||||||
|
```
|
||||||
|
|
||||||
---
|
## General Configuration
|
||||||
|
|
||||||
General Configuration:
|
Use the following general proxy configuration:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
```nginx
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -57,7 +61,6 @@ gzip_types
|
|||||||
text/plain
|
text/plain
|
||||||
text/xml;
|
text/xml;
|
||||||
|
|
||||||
# Nur aktiv lassen, wenn dein NPMplus-Image brotli unterstützt.
|
|
||||||
brotli on;
|
brotli on;
|
||||||
brotli_comp_level 5;
|
brotli_comp_level 5;
|
||||||
brotli_min_length 1024;
|
brotli_min_length 1024;
|
||||||
@@ -75,3 +78,4 @@ brotli_types
|
|||||||
|
|
||||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
add_header Vary "Accept-Encoding" always;
|
add_header Vary "Accept-Encoding" always;
|
||||||
|
```
|
||||||
Generated
+3998
-13350
File diff suppressed because it is too large.
Load diff
+25
-44
@@ -3,7 +3,7 @@
|
|||||||
"version": "YYDDDhhmm+GIT_HASH",
|
"version": "YYDDDhhmm+GIT_HASH",
|
||||||
"currentVersion": "3.2.0",
|
"currentVersion": "3.2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.14.0",
|
"node": ">=24.15.0",
|
||||||
"npm": ">=11.12.0"
|
"npm": ">=11.12.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"------ STARTUP": "",
|
"------ STARTUP": "",
|
||||||
"test": "npm run generate:assets && ng test",
|
"test": "npm run generate:assets && ng test",
|
||||||
"dev": "npm run generate:assets && ng serve",
|
"dev": "npm run generate:assets && ng serve",
|
||||||
"start": "node dist/server/server.mjs",
|
"start": "node scripts/static-server.mjs",
|
||||||
"------ CONFIGS": "",
|
"------ CONFIGS": "",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"repository": "https://github.com/FrauJulian/Personal-Portfolio-Website",
|
"repository": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website",
|
||||||
"bugs": "https://github.com/FrauJulian/Personal-Portfolio-Website/issues",
|
"bugs": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website/issues",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Lechner Julian",
|
"name": "Lechner Julian",
|
||||||
"nickname": "FrauJulian",
|
"nickname": "FrauJulian",
|
||||||
@@ -54,56 +54,37 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.13",
|
"@angular/common": "^22.2.1",
|
||||||
"@angular/cdk": "^21.2.11",
|
"@angular/compiler": "^22.2.1",
|
||||||
"@angular/common": "^21.2.13",
|
"@angular/core": "^22.2.1",
|
||||||
"@angular/compiler": "^21.2.13",
|
"@angular/platform-browser": "^22.2.1",
|
||||||
"@angular/core": "^21.2.13",
|
"@angular/router": "^22.2.1",
|
||||||
"@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",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0"
|
||||||
"zone.js": "~0.16.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^21.2.11",
|
"@angular/build": "^22.2.1",
|
||||||
"@angular/cli": "^21.2.11",
|
"@angular/cli": "^22.2.1",
|
||||||
"@angular/compiler-cli": "^21.2.13",
|
"@angular/compiler-cli": "^22.2.1",
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/jasmine": "~6.0.0",
|
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/showdown": "^2.0.6",
|
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
"@typescript-eslint/parser": "8.71.1",
|
||||||
"@typescript-eslint/parser": "8.59.3",
|
"angular-eslint": "22.5.0",
|
||||||
"angular-eslint": "21.4.0",
|
|
||||||
"del-cli": "^7.0.0",
|
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-import-resolver-typescript": "4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"eslint-plugin-n": "18.0.1",
|
"eslint-plugin-n": "^18.4.1",
|
||||||
"eslint-plugin-promise": "7.3.0",
|
"eslint-plugin-promise": "7.3.0",
|
||||||
"eslint-plugin-unused-imports": "4.4.1",
|
"eslint-plugin-unused-imports": "4.4.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jasmine-core": "~6.2.0",
|
"jsdom": "^30.1.2",
|
||||||
"karma": "~6.4.0",
|
"postcss": "^8.5.29",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
|
||||||
"karma-coverage": "~2.2.0",
|
|
||||||
"karma-jasmine": "~5.1.0",
|
|
||||||
"karma-jasmine-html-reporter": "~2.2.0",
|
|
||||||
"postcss": "^8.5.14",
|
|
||||||
"postcss-selector-parser": "^7.1.1",
|
"postcss-selector-parser": "^7.1.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.5",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "8.59.3"
|
"typescript-eslint": "8.71.1",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ const rasterExtensions = new Set([
|
|||||||
'.tif',
|
'.tif',
|
||||||
'.tiff',
|
'.tiff',
|
||||||
]);
|
]);
|
||||||
const passthroughExtensions = new Set(['.svg']);
|
const passthroughExtensions = new Set(['.svg', '.woff2']);
|
||||||
|
|
||||||
function collectFilesRecursively(directoryPath) {
|
function collectFilesRecursively(directoryPath) {
|
||||||
if (!fs.existsSync(directoryPath)) {
|
if (!fs.existsSync(directoryPath)) {
|
||||||
@@ -139,6 +139,32 @@ async function writeOptimizedAsset(sourceFilePath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconSourcePath = path.join(sourceRoot, 'portrait', 'me.png');
|
||||||
|
const iconOutputs = [
|
||||||
|
{ fileName: 'icons/favicon-32.png', width: 32, height: 32, fit: 'cover', format: 'png' },
|
||||||
|
{ fileName: 'icons/apple-touch-icon.png', width: 180, height: 180, fit: 'cover', format: 'png' },
|
||||||
|
{ fileName: 'icons/og-image.jpg', width: 1200, height: 630, fit: 'contain', format: 'jpeg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Favicons and social previews need PNG/JPEG because not every client accepts WebP.
|
||||||
|
async function writeIconAssets() {
|
||||||
|
if (!fs.existsSync(iconSourcePath)) {
|
||||||
|
console.warn('Icon generation skipped: portrait/me.png not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { fileName, width, height, fit, format } of iconOutputs) {
|
||||||
|
const targetFilePath = path.join(targetRoot, fileName);
|
||||||
|
fs.mkdirSync(path.dirname(targetFilePath), { recursive: true });
|
||||||
|
await sharp(iconSourcePath)
|
||||||
|
.resize({ width, height, fit, position: 'top', background: '#08080b' })
|
||||||
|
.flatten({ background: '#08080b' })
|
||||||
|
.toFormat(format, { quality: 86 })
|
||||||
|
.toFile(targetFilePath);
|
||||||
|
console.log(`Generated: ${fileName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!fs.existsSync(sourceRoot)) {
|
if (!fs.existsSync(sourceRoot)) {
|
||||||
console.log('Optimized asset generation skipped: no unoptimized asset directory found.');
|
console.log('Optimized asset generation skipped: no unoptimized asset directory found.');
|
||||||
@@ -175,6 +201,8 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await writeIconAssets();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Optimized asset generation complete. Converted: ${convertedCount}, copied: ${copiedCount}, net saved: ${totalSavedBytes} bytes.`,
|
`Optimized asset generation complete. Converted: ${convertedCount}, copied: ${copiedCount}, net saved: ${totalSavedBytes} bytes.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createBrotliCompress, createGzip } from 'node:zlib';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const appRoot = path.resolve(__dirname, '..');
|
||||||
|
const distRoot = path.join(appRoot, 'dist');
|
||||||
|
const browserRoot = path.join(distRoot, 'browser');
|
||||||
|
const port = 3000;
|
||||||
|
const indexPath = path.join(browserRoot, 'index.html');
|
||||||
|
// Keep in sync with src/app/app.routes.ts.
|
||||||
|
const appRoutes = new Set(['/', '/imprint']);
|
||||||
|
|
||||||
|
const mimeTypes = new Map([
|
||||||
|
['.css', 'text/css; charset=utf-8'],
|
||||||
|
['.html', 'text/html; charset=utf-8'],
|
||||||
|
['.ico', 'image/x-icon'],
|
||||||
|
['.jpg', 'image/jpeg'],
|
||||||
|
['.js', 'text/javascript; charset=utf-8'],
|
||||||
|
['.json', 'application/json; charset=utf-8'],
|
||||||
|
['.map', 'application/json; charset=utf-8'],
|
||||||
|
['.png', 'image/png'],
|
||||||
|
['.svg', 'image/svg+xml'],
|
||||||
|
['.txt', 'text/plain; charset=utf-8'],
|
||||||
|
['.webp', 'image/webp'],
|
||||||
|
['.woff2', 'font/woff2'],
|
||||||
|
['.xml', 'application/xml; charset=utf-8'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const compressibleExtensions = new Set([
|
||||||
|
'.css',
|
||||||
|
'.html',
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.map',
|
||||||
|
'.svg',
|
||||||
|
'.txt',
|
||||||
|
'.xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const securityHeaders = {
|
||||||
|
'Permissions-Policy': 'camera=(), geolocation=(), microphone=()',
|
||||||
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||||
|
'Strict-Transport-Security': 'max-age=31536000',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheControl(filePath) {
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const extension = path.extname(fileName).toLowerCase();
|
||||||
|
|
||||||
|
if (extension === '.html') {
|
||||||
|
return 'no-cache, must-revalidate';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\-[A-Z0-9]{8,}\.(css|js)$/i.test(fileName)) {
|
||||||
|
return 'public, max-age=31536000, immutable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'.css',
|
||||||
|
'.ico',
|
||||||
|
'.jpg',
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.map',
|
||||||
|
'.png',
|
||||||
|
'.svg',
|
||||||
|
'.txt',
|
||||||
|
'.webp',
|
||||||
|
'.woff2',
|
||||||
|
'.xml',
|
||||||
|
].includes(extension)
|
||||||
|
) {
|
||||||
|
return normalizedPath.includes('/browser/')
|
||||||
|
? 'public, max-age=604800, stale-while-revalidate=86400'
|
||||||
|
: 'public, max-age=86400, stale-while-revalidate=3600';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'public, max-age=3600';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFile(targetPath) {
|
||||||
|
try {
|
||||||
|
return (await stat(targetPath)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInside(root, relativePath) {
|
||||||
|
const candidate = path.resolve(root, relativePath);
|
||||||
|
return candidate === root || candidate.startsWith(`${root}${path.sep}`) ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFile(urlPath) {
|
||||||
|
const normalized = path.posix.normalize(decodeURIComponent(urlPath));
|
||||||
|
const relativePath = normalized.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
for (const root of [distRoot, browserRoot]) {
|
||||||
|
const candidate = resolveInside(root, relativePath);
|
||||||
|
if (candidate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isFile(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEncoding(request, filePath) {
|
||||||
|
if (!compressibleExtensions.has(path.extname(filePath).toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptEncoding = request.headers['accept-encoding'] ?? '';
|
||||||
|
if (/\bbr\b/.test(acceptEncoding)) {
|
||||||
|
return 'br';
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\bgzip\b/.test(acceptEncoding) ? 'gzip' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFile(request, response, filePath, statusCode) {
|
||||||
|
const encoding = getEncoding(request, filePath);
|
||||||
|
const headers = {
|
||||||
|
...securityHeaders,
|
||||||
|
'Cache-Control': statusCode === 200 ? getCacheControl(filePath) : 'no-cache',
|
||||||
|
'Content-Type': getContentType(filePath),
|
||||||
|
Vary: 'Accept-Encoding',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encoding !== null) {
|
||||||
|
headers['Content-Encoding'] = encoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(statusCode, headers);
|
||||||
|
|
||||||
|
if (request.method === 'HEAD') {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = createReadStream(filePath);
|
||||||
|
if (encoding === null) {
|
||||||
|
await pipeline(source, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(source, encoding === 'br' ? createBrotliCompress() : createGzip(), response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendText(response, statusCode, message, extraHeaders = {}) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
...securityHeaders,
|
||||||
|
...extraHeaders,
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
});
|
||||||
|
response.end(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
|
sendText(response, 405, 'Method not allowed', { Allow: 'GET, HEAD' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
||||||
|
const filePath = await resolveFile(requestUrl.pathname);
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
await sendFile(request, response, filePath, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routePath = requestUrl.pathname.replace(/(.)\/+$/, '$1');
|
||||||
|
if (appRoutes.has(routePath)) {
|
||||||
|
await sendFile(request, response, indexPath, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown routes get a 404 status but still boot the app, which redirects to the start page.
|
||||||
|
if (!path.posix.extname(requestUrl.pathname)) {
|
||||||
|
await sendFile(request, response, indexPath, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendText(response, 404, 'Not found');
|
||||||
|
} catch (error) {
|
||||||
|
if (!response.headersSent) {
|
||||||
|
sendText(response, 500, 'Internal server error');
|
||||||
|
} else {
|
||||||
|
response.destroy();
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`Serving static app on port ${port}`);
|
||||||
|
});
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
|
const lastModified = '2026-10-15';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: 'https://fraujulian.xyz',
|
siteUrl: 'https://fraujulian.xyz',
|
||||||
sitemap: {
|
sitemap: {
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{ path: '/', lastModified, changeFrequency: 'monthly', priority: '1.0' },
|
||||||
path: '/',
|
{ path: '/?lang=de', lastModified, changeFrequency: 'monthly', priority: '1.0' },
|
||||||
lastModified: '2026-04-17',
|
{ path: '/imprint', lastModified, changeFrequency: 'yearly', priority: '0.3' },
|
||||||
changeFrequency: 'monthly',
|
{ path: '/imprint?lang=de', lastModified, changeFrequency: 'yearly', priority: '0.3' },
|
||||||
priority: '1.0',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
|
|||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
<div class="app-content" [attr.aria-hidden]="isLanguageSelectorOpen() ? 'true' : null" [attr.inert]="isLanguageSelectorOpen() ? '' : null">
|
<div class="app-content">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (isLanguageSelectorOpen()) {
|
<dialog
|
||||||
<div
|
#languageDialog
|
||||||
class="language-overlay"
|
class="language-overlay"
|
||||||
role="dialog"
|
aria-labelledby="language-title"
|
||||||
aria-modal="true"
|
(close)="closeLanguageSelector()"
|
||||||
aria-labelledby="language-title"
|
(click)="closeLanguageSelectorOnBackdrop($event)"
|
||||||
(click)="closeLanguageSelectorOnBackdrop($event)"
|
>
|
||||||
>
|
@if (isLanguageSelectorOpen()) {
|
||||||
<section class="language-card panel">
|
<section class="language-card panel">
|
||||||
<div class="language-card-header">
|
<div class="language-card-header">
|
||||||
<div class="language-copy">
|
<div class="language-copy">
|
||||||
@@ -57,5 +57,5 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
}
|
||||||
}
|
</dialog>
|
||||||
@@ -27,11 +27,13 @@ describe('AppComponent', (): void => {
|
|||||||
|
|
||||||
it('should keep the language selector closed on first visit', (): void => {
|
it('should keep the language selector closed on first visit', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.querySelector('.language-overlay')).toBeNull();
|
expect(el.querySelector<HTMLDialogElement>('dialog.language-overlay')?.open).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preselect English by default', (): void => {
|
it('should preselect English by default', (): void => {
|
||||||
const comp = component as unknown as { selectedLanguage(): string };
|
const comp = component as unknown as {
|
||||||
|
selectedLanguage(): string;
|
||||||
|
};
|
||||||
expect(comp.selectedLanguage()).toBe('en');
|
expect(comp.selectedLanguage()).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +51,6 @@ describe('AppComponent', (): void => {
|
|||||||
const comp = component as unknown as {
|
const comp = component as unknown as {
|
||||||
chooseLanguage(language: 'en' | 'de'): void;
|
chooseLanguage(language: 'en' | 'de'): void;
|
||||||
currentLanguageCode(): string;
|
currentLanguageCode(): string;
|
||||||
isLanguageConfirmed(): boolean;
|
|
||||||
isLanguageSelectorOpen(): boolean;
|
isLanguageSelectorOpen(): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,8 +58,7 @@ describe('AppComponent', (): void => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(comp.currentLanguageCode()).toBe('de');
|
expect(comp.currentLanguageCode()).toBe('de');
|
||||||
expect(comp.isLanguageConfirmed()).toBeTrue();
|
expect(comp.isLanguageSelectorOpen()).toBe(false);
|
||||||
expect(comp.isLanguageSelectorOpen()).toBeFalse();
|
|
||||||
expect(localStorage.getItem('portfolio-language')).toBe('de');
|
expect(localStorage.getItem('portfolio-language')).toBe('de');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,13 +69,11 @@ describe('AppComponent', (): void => {
|
|||||||
restoredFixture.detectChanges();
|
restoredFixture.detectChanges();
|
||||||
const restoredComponent = restoredFixture.componentInstance as unknown as {
|
const restoredComponent = restoredFixture.componentInstance as unknown as {
|
||||||
currentLanguageCode(): string;
|
currentLanguageCode(): string;
|
||||||
isLanguageConfirmed(): boolean;
|
|
||||||
isLanguageSelectorOpen(): boolean;
|
isLanguageSelectorOpen(): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(restoredComponent.currentLanguageCode()).toBe('de');
|
expect(restoredComponent.currentLanguageCode()).toBe('de');
|
||||||
expect(restoredComponent.isLanguageConfirmed()).toBeTrue();
|
expect(restoredComponent.isLanguageSelectorOpen()).toBe(false);
|
||||||
expect(restoredComponent.isLanguageSelectorOpen()).toBeFalse();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reopen the selector after confirmation', (): void => {
|
it('should reopen the selector after confirmation', (): void => {
|
||||||
@@ -89,6 +87,20 @@ describe('AppComponent', (): void => {
|
|||||||
comp.reopenLanguageSelector();
|
comp.reopenLanguageSelector();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(comp.isLanguageSelectorOpen()).toBeTrue();
|
expect(comp.isLanguageSelectorOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync the selector state when the dialog closes natively', (): void => {
|
||||||
|
const comp = component as unknown as {
|
||||||
|
reopenLanguageSelector(): void;
|
||||||
|
isLanguageSelectorOpen(): boolean;
|
||||||
|
};
|
||||||
|
comp.reopenLanguageSelector();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const dialog = (fixture.nativeElement as HTMLElement).querySelector('dialog');
|
||||||
|
dialog?.dispatchEvent(new Event('close'));
|
||||||
|
|
||||||
|
expect(comp.isLanguageSelectorOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+20
-26
@@ -1,17 +1,18 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
|
||||||
import {
|
import {
|
||||||
|
afterRenderEffect,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import type { OnDestroy, OnInit } from '@angular/core';
|
import type { ElementRef, OnInit } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
import type { LanguageCode } from '../languages/language.types';
|
import type { LanguageCode } from '../languages/language.types';
|
||||||
import type { LanguageOption } from './app.types';
|
import type { LanguageOption } from './app.types';
|
||||||
|
import { LanguagePreferenceService } from './services/language-preference.service';
|
||||||
import { ShellLanguageService } from './services/shell-language.service';
|
import { ShellLanguageService } from './services/shell-language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -21,13 +22,14 @@ import { ShellLanguageService } from './services/shell-language.service';
|
|||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit {
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly languagePreferenceService = inject(LanguagePreferenceService);
|
||||||
private readonly shellLanguageService = inject(ShellLanguageService);
|
private readonly shellLanguageService = inject(ShellLanguageService);
|
||||||
|
private readonly languageDialog =
|
||||||
|
viewChild.required<ElementRef<HTMLDialogElement>>('languageDialog');
|
||||||
|
|
||||||
protected readonly content = this.shellLanguageService.content;
|
protected readonly content = this.shellLanguageService.content;
|
||||||
protected readonly currentLanguageCode = this.shellLanguageService.languageCode;
|
protected readonly currentLanguageCode = this.languagePreferenceService.languageCode;
|
||||||
protected readonly isLanguageConfirmed = this.shellLanguageService.isLanguageConfirmed;
|
|
||||||
protected readonly isLanguageSelectorOpen = signal(false);
|
protected readonly isLanguageSelectorOpen = signal(false);
|
||||||
protected readonly selectedLanguage = signal<LanguageCode>('en');
|
protected readonly selectedLanguage = signal<LanguageCode>('en');
|
||||||
protected readonly languageOptions: readonly LanguageOption[] = [
|
protected readonly languageOptions: readonly LanguageOption[] = [
|
||||||
@@ -41,26 +43,22 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
|
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
|
||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
private readonly syncDialog = afterRenderEffect((): void => {
|
||||||
effect((): void => {
|
const dialog = this.languageDialog().nativeElement;
|
||||||
const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : '';
|
if (this.isLanguageSelectorOpen() && !dialog.open) {
|
||||||
this.document.body.style.overflow = overflowValue;
|
dialog.showModal();
|
||||||
this.document.documentElement.style.overflow = overflowValue;
|
} else if (!this.isLanguageSelectorOpen() && dialog.open) {
|
||||||
});
|
dialog.close();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const initialLanguage = this.shellLanguageService.initializeFromStorage();
|
this.selectedLanguage.set(this.languagePreferenceService.initialize());
|
||||||
this.selectedLanguage.set(initialLanguage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected chooseLanguage(language: LanguageCode): void {
|
protected chooseLanguage(language: LanguageCode): void {
|
||||||
this.selectedLanguage.set(language);
|
this.selectedLanguage.set(language);
|
||||||
this.confirmLanguage();
|
this.languagePreferenceService.confirmLanguage(language);
|
||||||
}
|
|
||||||
|
|
||||||
protected confirmLanguage(): void {
|
|
||||||
this.shellLanguageService.confirmLanguage(this.selectedLanguage());
|
|
||||||
this.isLanguageSelectorOpen.set(false);
|
this.isLanguageSelectorOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.isLanguageSelectorOpen.set(true);
|
this.isLanguageSelectorOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Also runs when the dialog closes natively, e.g. via Escape. */
|
||||||
protected closeLanguageSelector(): void {
|
protected closeLanguageSelector(): void {
|
||||||
this.selectedLanguage.set(this.currentLanguageCode());
|
this.selectedLanguage.set(this.currentLanguageCode());
|
||||||
this.isLanguageSelectorOpen.set(false);
|
this.isLanguageSelectorOpen.set(false);
|
||||||
@@ -83,9 +82,4 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string {
|
protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string {
|
||||||
return language === 'de' ? content.app.languageGerman : content.app.languageEnglish;
|
return language === 'de' ? content.app.languageGerman : content.app.languageEnglish;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.document.body.style.overflow = '';
|
|
||||||
this.document.documentElement.style.overflow = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { provideServerRendering } from '@angular/ssr';
|
|
||||||
import type { ApplicationConfig } from '@angular/core';
|
|
||||||
import { mergeApplicationConfig } from '@angular/core';
|
|
||||||
|
|
||||||
import { appConfig } from './app.config';
|
|
||||||
|
|
||||||
const serverConfig: ApplicationConfig = {
|
|
||||||
providers: [provideServerRendering()],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config: ApplicationConfig = mergeApplicationConfig(appConfig, serverConfig);
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ApplicationConfig } from '@angular/core';
|
import type { ApplicationConfig } from '@angular/core';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { inject, provideAppInitializer, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { SeoService } from './services/seo.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -14,5 +15,8 @@ export const appConfig: ApplicationConfig = {
|
|||||||
scrollPositionRestoration: 'top',
|
scrollPositionRestoration: 'top',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
provideAppInitializer((): void => {
|
||||||
|
inject(SeoService);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
<noscript>
|
|
||||||
<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 class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<p>© 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
|
<p>© 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
|
||||||
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
|
@if (showImprintLink()) {
|
||||||
|
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
|
||||||
|
} @else {
|
||||||
|
<a routerLink="/">{{ content().footer.homeLink }}</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -80,6 +80,13 @@ describe('FooterComponent', (): void => {
|
|||||||
expect(imprintLink).toBeTruthy();
|
expect(imprintLink).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should link back to the portfolio instead of the imprint on the imprint page', (): void => {
|
||||||
|
fixture.componentRef.setInput('showImprintLink', false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const links = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('a'));
|
||||||
|
expect(links.map((a): string => a.textContent?.trim() ?? '')).toEqual(['Portfolio']);
|
||||||
|
});
|
||||||
|
|
||||||
it('should contain a footer element', (): void => {
|
it('should contain a footer element', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.querySelector('footer')).not.toBeNull();
|
expect(el.querySelector('footer')).not.toBeNull();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import { ShellLanguageService } from '../services/shell-language.service';
|
import { ShellLanguageService } from '../services/shell-language.service';
|
||||||
@@ -16,4 +16,7 @@ export class FooterComponent {
|
|||||||
protected readonly content = this.shellLanguageService.content;
|
protected readonly content = this.shellLanguageService.content;
|
||||||
protected readonly global = global;
|
protected readonly global = global;
|
||||||
protected readonly currentYear = new Date().getFullYear();
|
protected readonly currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
/** The imprint page links back to the portfolio instead of to itself. */
|
||||||
|
readonly showImprintLink = input(true);
|
||||||
}
|
}
|
||||||
@@ -11,67 +11,75 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-action-links">
|
<div class="hero-action-links">
|
||||||
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
|
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
|
||||||
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
|
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hero-contact-block">
|
||||||
|
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
|
||||||
<div class="hero-meta">
|
<div id="contact-links" class="contact-area hero-contact-area">
|
||||||
<div>
|
<a [href]="contactMailHref" [attr.aria-label]="content().home.mailAriaLabel"
|
||||||
<span class="meta-label">{{ currentBioEntry.label }}</span
|
><app-icon [icon]="envelopeIcon" size="lg"></app-icon
|
||||||
><strong>{{ currentBioEntry.value }}</strong>
|
></a>
|
||||||
|
<a href="tel:{{ global.hrefContactPhone }}" [attr.aria-label]="content().home.phoneAriaLabel"
|
||||||
|
><app-icon [icon]="phoneIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a [href]="global.signalUrl" [attr.aria-label]="content().home.signalAriaLabel"
|
||||||
|
><app-icon [icon]="signalIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a href="https://git.lechner-systems.at/fraujulian" [attr.aria-label]="content().home.giteaAriaLabel"
|
||||||
|
><app-icon [icon]="giteaIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a href="https://www.linkedin.com/in/julianlechner2009/" [attr.aria-label]="content().home.linkedinAriaLabel"
|
||||||
|
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual">
|
<div class="hero-visual">
|
||||||
@if (currentPortraitHighlight) {
|
@if (currentPortraitHighlight(); as highlight) {
|
||||||
<div class="portrait-highlight">
|
<div class="portrait-highlight">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="portrait-highlight-button"
|
class="portrait-highlight-button"
|
||||||
(click)="showNextPortraitHighlight()"
|
(click)="showNextPortraitHighlight()"
|
||||||
[attr.aria-label]="content().home.nextHighlightAriaLabel"
|
[attr.aria-label]="content().home.nextHighlightAriaLabel"
|
||||||
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
|
[title]="portraitHighlights().length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
|
||||||
>
|
>
|
||||||
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
|
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching()">
|
||||||
<img
|
<img
|
||||||
class="hero-feature-image"
|
class="hero-feature-image"
|
||||||
[src]="currentPortraitHighlight.image"
|
[src]="highlight.image"
|
||||||
[attr.srcset]="currentPortraitHighlight.imageSrcset"
|
[attr.srcset]="highlight.imageSrcset"
|
||||||
[attr.sizes]="currentPortraitHighlight.imageSizes"
|
[attr.sizes]="highlight.imageSizes"
|
||||||
[width]="currentPortraitHighlight.imageWidth"
|
[width]="highlight.imageWidth"
|
||||||
[height]="currentPortraitHighlight.imageHeight"
|
[height]="highlight.imageHeight"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
|
[alt]="highlight.text + ' ' + content().home.portraitAltSuffix"
|
||||||
/>
|
/>
|
||||||
@if (currentPortraitHighlightIndex === 0) {
|
@if (currentPortraitHighlightIndex() === 0) {
|
||||||
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
|
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
|
||||||
}
|
}
|
||||||
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
|
<span class="portrait-caption">{{ highlight.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<a
|
||||||
|
class="hero-meta"
|
||||||
|
[href]="global.linkedinSkillsUrl"
|
||||||
|
[attr.aria-label]="content().home.skillsLinkAriaLabel"
|
||||||
|
(mouseenter)="isBioTickerPaused.set(true)"
|
||||||
|
(mouseleave)="isBioTickerPaused.set(false)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="meta-label">{{ content().home.skillsLabel }}</span
|
||||||
|
><strong>{{ currentBioText() }}</strong>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -87,11 +95,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
||||||
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
|
<span>{{ isLongBioShown() ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
|
||||||
><app-icon [icon]="isLongBioShown ? arrowDownIcon : arrowRightIcon"></app-icon>
|
><app-icon [icon]="isLongBioShown() ? arrowDownIcon : arrowRightIcon"></app-icon>
|
||||||
</button>
|
</button>
|
||||||
@if (isLongBioMounted) {
|
@if (isLongBioMounted()) {
|
||||||
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
|
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown()" [class.detail-panel-leave]="!isLongBioShown()">
|
||||||
<p>{{ content().home.detailParagraphs[0] }}</p>
|
<p>{{ content().home.detailParagraphs[0] }}</p>
|
||||||
<p>{{ content().home.detailParagraphs[1] }}</p>
|
<p>{{ content().home.detailParagraphs[1] }}</p>
|
||||||
<p>{{ content().home.detailParagraphs[2] }}</p>
|
<p>{{ content().home.detailParagraphs[2] }}</p>
|
||||||
@@ -109,7 +117,7 @@
|
|||||||
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
|
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-list">
|
<div class="project-list">
|
||||||
@for (project of projects; track project.link + '-' + $index; let i = $index) {
|
@for (project of projects(); track project.link + '-' + $index; let i = $index) {
|
||||||
<a
|
<a
|
||||||
class="panel project-entry"
|
class="panel project-entry"
|
||||||
[class.project-entry-reverse]="i % 2 === 1"
|
[class.project-entry-reverse]="i % 2 === 1"
|
||||||
@@ -123,24 +131,25 @@
|
|||||||
<img
|
<img
|
||||||
class="project-icon"
|
class="project-icon"
|
||||||
[class.project-icon-circle]="project.circleIcon !== false"
|
[class.project-icon-circle]="project.circleIcon !== false"
|
||||||
[ngSrc]="project.icon"
|
[src]="project.icon"
|
||||||
[attr.srcset]="project.iconSrcset ?? null"
|
[attr.srcset]="project.iconSrcset ?? null"
|
||||||
[attr.sizes]="project.iconSizes ?? null"
|
[attr.sizes]="project.iconSizes ?? null"
|
||||||
[width]="project.iconWidth ?? 216"
|
[width]="project.iconWidth ?? 216"
|
||||||
[height]="project.iconHeight ?? 216"
|
[height]="project.iconHeight ?? 216"
|
||||||
disableOptimizedSrcset
|
loading="eager"
|
||||||
[alt]="project.title + ' icon'"
|
fetchpriority="low"
|
||||||
|
decoding="async"
|
||||||
|
[alt]="project.title + ' ' + content().home.projectIconAltSuffix"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<div class="project-copy">
|
<div class="project-copy">
|
||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="project-title-wrap">
|
<div class="project-title-wrap">
|
||||||
<span class="project-link">{{ project.title }}</span>
|
<span class="project-link"
|
||||||
</div>
|
>{{ project.titleHead
|
||||||
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel">
|
}}<span class="project-title-tail"
|
||||||
@for (language of project.skills; track language + '-' + $index) {
|
>{{ project.titleTail }}<app-icon class="project-arrow" [icon]="arrowRightIcon"></app-icon></span
|
||||||
<span class="project-language-chip">{{ language }}</span>
|
></span>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="project-description">{{ project.description }}</p>
|
<p class="project-description">{{ project.description }}</p>
|
||||||
@@ -151,4 +160,4 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<app-footer ngSkipHydration></app-footer>
|
<app-footer></app-footer>
|
||||||
+199
-171
@@ -1,44 +1,61 @@
|
|||||||
|
import type { Mock } from 'vitest';
|
||||||
import type { ComponentFixture } from '@angular/core/testing';
|
import type { ComponentFixture } from '@angular/core/testing';
|
||||||
import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { IMAGE_LOADER } from '@angular/common';
|
|
||||||
import type { ImageLoaderConfig } from '@angular/common';
|
|
||||||
import { NgZone } from '@angular/core';
|
import { NgZone } from '@angular/core';
|
||||||
|
import type { Signal, WritableSignal } from '@angular/core';
|
||||||
|
|
||||||
import { HomeComponent } from './home.component';
|
import { HomeComponent } from './home.component';
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import { enLanguage } from '../../languages/en';
|
import { enLanguage } from '../../languages/en';
|
||||||
import type {
|
import type { LanguagePortraitHighlight } from '../../languages/language.types';
|
||||||
LanguageBioTextEntry,
|
|
||||||
LanguagePortraitHighlight,
|
|
||||||
} from '../../languages/language.types';
|
|
||||||
|
|
||||||
describe('HomeComponent', (): void => {
|
describe('HomeComponent', (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
// performance stays real so the change detection timing tests keep measuring.
|
||||||
|
vi.useFakeTimers({
|
||||||
|
toFake: [
|
||||||
|
'setTimeout',
|
||||||
|
'clearTimeout',
|
||||||
|
'setInterval',
|
||||||
|
'clearInterval',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
'cancelAnimationFrame',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// jsdom does not implement matchMedia.
|
||||||
|
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: false }) as MediaQueryList);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
let fixture: ComponentFixture<HomeComponent>;
|
let fixture: ComponentFixture<HomeComponent>;
|
||||||
let component: HomeComponent;
|
let component: HomeComponent;
|
||||||
|
|
||||||
interface ComponentAccess {
|
interface ComponentAccess {
|
||||||
age: number;
|
age: number;
|
||||||
currentIndex: number;
|
currentIndex: WritableSignal<number>;
|
||||||
isLongBioShown: boolean;
|
isBioTickerPaused: WritableSignal<boolean>;
|
||||||
isLongBioMounted: boolean;
|
isLongBioShown: WritableSignal<boolean>;
|
||||||
isPortraitSwitching: boolean;
|
isLongBioMounted: WritableSignal<boolean>;
|
||||||
currentPortraitHighlightIndex: number;
|
isPortraitSwitching: WritableSignal<boolean>;
|
||||||
|
currentPortraitHighlightIndex: WritableSignal<number>;
|
||||||
|
portraitHighlights: Signal<LanguagePortraitHighlight[]>;
|
||||||
bioHideTimeoutId: number | null;
|
bioHideTimeoutId: number | null;
|
||||||
scrollAnimationFrameId: number | null;
|
scrollEffectsAnimationFrameId: number | null;
|
||||||
projectEntryAnimationFrameId: number | null;
|
scheduleScrollEffectsUpdate(): void;
|
||||||
aboutSectionAnimationFrameId: number | null;
|
|
||||||
scheduleNameGradientUpdate(): void;
|
|
||||||
scheduleProjectEntryRevealUpdate(): void;
|
|
||||||
scheduleAboutSectionScrollAnimationUpdate(): void;
|
|
||||||
shouldEnableScrollEffects(): boolean;
|
shouldEnableScrollEffects(): boolean;
|
||||||
toggleBio(): void;
|
toggleBio(): void;
|
||||||
showNextPortraitHighlight(): void;
|
showNextPortraitHighlight(): void;
|
||||||
scrollToAboutSection(): void;
|
scrollToAboutSection(): void;
|
||||||
scrollToProjectsSection(): void;
|
scrollToProjectsSection(): void;
|
||||||
scrollToContactLinks(): void;
|
scrollToContactLinks(): void;
|
||||||
currentBioEntry: LanguageBioTextEntry;
|
currentBioText: Signal<string>;
|
||||||
currentPortraitHighlight: LanguagePortraitHighlight | null;
|
currentPortraitHighlight: Signal<LanguagePortraitHighlight | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let comp: ComponentAccess;
|
let comp: ComponentAccess;
|
||||||
@@ -46,13 +63,7 @@ describe('HomeComponent', (): void => {
|
|||||||
beforeEach(async (): Promise<void> => {
|
beforeEach(async (): Promise<void> => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [HomeComponent],
|
imports: [HomeComponent],
|
||||||
providers: [
|
providers: [provideRouter([])],
|
||||||
provideRouter([]),
|
|
||||||
{
|
|
||||||
provide: IMAGE_LOADER,
|
|
||||||
useValue: (config: ImageLoaderConfig): string => config.src,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(HomeComponent);
|
fixture = TestBed.createComponent(HomeComponent);
|
||||||
@@ -62,9 +73,8 @@ describe('HomeComponent', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach((): void => {
|
afterEach((): void => {
|
||||||
// Explicitly destroy to stop the interval(1000) subscription started in ngOnInit.
|
// Explicitly destroy to stop the bio ticker interval started after the first render.
|
||||||
// Without this, the real setInterval keeps the browser's event loop alive after
|
// Without this, the interval keeps firing after the test has finished.
|
||||||
// the test suite completes, causing Karma to disconnect with a 30 s timeout.
|
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,40 +103,69 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
// ── Bio cycling ────────────────────────────────────────────────────────────
|
// ── Bio cycling ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('currentBioEntry getter', (): void => {
|
describe('currentBioText', (): void => {
|
||||||
it('should return the first bioTextsList entry at index 0', (): void => {
|
it('should return the first bioTextsList entry at index 0', (): void => {
|
||||||
comp.currentIndex = 0;
|
comp.currentIndex.set(0);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct entry when index changes', (): void => {
|
it('should return the correct entry when index changes', (): void => {
|
||||||
comp.currentIndex = 3;
|
comp.currentIndex.set(3);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the fallback entry when index is out of range', (): void => {
|
it('should return an empty text when index is out of range', (): void => {
|
||||||
comp.currentIndex = 99999;
|
comp.currentIndex.set(99999);
|
||||||
expect(comp.currentBioEntry.label).toBeDefined();
|
expect(comp.currentBioText()).toBe('');
|
||||||
expect(comp.currentBioEntry.value).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interval-driven bio cycling', (): void => {
|
describe('interval-driven bio cycling', (): void => {
|
||||||
|
it('should advance to the next entry every 1250 ms', async (): Promise<void> => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1249);
|
||||||
|
expect(comp.currentIndex()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(comp.currentIndex()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pause while the bio is hovered', async (): Promise<void> => {
|
||||||
|
const meta = (fixture.nativeElement as HTMLElement).querySelector('.hero-meta');
|
||||||
|
meta?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
await vi.advanceTimersByTimeAsync(2500);
|
||||||
|
expect(comp.currentIndex()).toBe(0);
|
||||||
|
|
||||||
|
meta?.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await vi.advanceTimersByTimeAsync(1250);
|
||||||
|
expect(comp.currentIndex()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cycle when reduced motion is requested', async (): Promise<void> => {
|
||||||
|
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: true }) as MediaQueryList);
|
||||||
|
const reducedFixture = TestBed.createComponent(HomeComponent);
|
||||||
|
reducedFixture.detectChanges();
|
||||||
|
const reduced = reducedFixture.componentInstance as unknown as ComponentAccess;
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(reduced.currentIndex()).toBe(0);
|
||||||
|
reducedFixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('should start with currentIndex 0', (): void => {
|
it('should start with currentIndex 0', (): void => {
|
||||||
expect(comp.currentIndex).toBe(0);
|
expect(comp.currentIndex()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should expose a valid bio entry for the current index', (): void => {
|
it('should expose a valid bio entry for the current index', (): void => {
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
|
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
|
||||||
const last = enLanguage.bioTextsList.length - 1;
|
const last = enLanguage.bioTextsList.length - 1;
|
||||||
comp.currentIndex = last;
|
comp.currentIndex.set(last);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]);
|
||||||
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
|
comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length);
|
||||||
expect(comp.currentIndex).toBe(0);
|
expect(comp.currentIndex()).toBe(0);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,156 +173,165 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
describe('currentPortraitHighlight getter', (): void => {
|
describe('currentPortraitHighlight getter', (): void => {
|
||||||
it('should return the highlight at the current index', (): void => {
|
it('should return the highlight at the current index', (): void => {
|
||||||
comp.currentPortraitHighlightIndex = 0;
|
comp.currentPortraitHighlightIndex.set(0);
|
||||||
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]);
|
expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when index is out of range', (): void => {
|
it('should return null when index is out of range', (): void => {
|
||||||
comp.currentPortraitHighlightIndex = 99999;
|
comp.currentPortraitHighlightIndex.set(99999);
|
||||||
expect(comp.currentPortraitHighlight).toBeNull();
|
expect(comp.currentPortraitHighlight()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('portrait preloading', (): void => {
|
||||||
|
it('should preload every highlight image except the visible one', (): void => {
|
||||||
|
const preloaded: string[] = [];
|
||||||
|
vi.spyOn(window, 'Image').mockImplementation(function (): HTMLImageElement {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
Object.defineProperty(image, 'src', {
|
||||||
|
set: (value: string): void => {
|
||||||
|
preloaded.push(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
|
||||||
|
const preloadFixture = TestBed.createComponent(HomeComponent);
|
||||||
|
preloadFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(preloaded).toEqual(enLanguage.portraitHighlights.slice(1).map((h) => h.image));
|
||||||
|
preloadFixture.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('showNextPortraitHighlight', (): void => {
|
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 => {
|
it('should set isPortraitSwitching to true immediately', (): void => {
|
||||||
comp.isPortraitSwitching = false;
|
comp.isPortraitSwitching.set(false);
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
expect(comp.isPortraitSwitching).toBeTrue();
|
expect(comp.isPortraitSwitching()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not advance index when already switching', (): void => {
|
it('should not advance index when already switching', (): void => {
|
||||||
comp.isPortraitSwitching = true;
|
comp.isPortraitSwitching.set(true);
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(0);
|
expect(comp.currentPortraitHighlightIndex()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call setTimeout when only one portrait exists', (): void => {
|
it('should not call setTimeout when only one portrait exists', (): void => {
|
||||||
const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] })
|
const highlights = comp.portraitHighlights();
|
||||||
.portraitHighlights;
|
|
||||||
const saved = [...highlights]; // snapshot before mutation
|
const saved = [...highlights]; // snapshot before mutation
|
||||||
highlights.splice(1, saved.length - 1); // reduce to a single entry
|
highlights.splice(1, saved.length - 1); // reduce to a single entry
|
||||||
|
|
||||||
const setSpy = spyOn(window, 'setTimeout');
|
const setSpy = vi.spyOn(window, 'setTimeout');
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
expect(setSpy).not.toHaveBeenCalled();
|
expect(setSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
highlights.splice(0, highlights.length, ...saved); // restore from snapshot
|
highlights.splice(0, highlights.length, ...saved); // restore from snapshot
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should advance the portrait index after 125 ms', fakeAsync((): void => {
|
it('should advance the portrait index after 125 ms', async (): Promise<void> => {
|
||||||
const zone = TestBed.inject(NgZone);
|
const zone = TestBed.inject(NgZone);
|
||||||
zone.runOutsideAngular((): void => {
|
zone.runOutsideAngular((): void => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
tick(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(1);
|
expect(comp.currentPortraitHighlightIndex()).toBe(1);
|
||||||
tick(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should reset isPortraitSwitching to false after 250 ms', fakeAsync((): void => {
|
it('should reset isPortraitSwitching to false after 250 ms', async (): Promise<void> => {
|
||||||
const zone = TestBed.inject(NgZone);
|
const zone = TestBed.inject(NgZone);
|
||||||
zone.runOutsideAngular((): void => {
|
zone.runOutsideAngular((): void => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
tick(250);
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
expect(comp.isPortraitSwitching).toBeFalse();
|
expect(comp.isPortraitSwitching()).toBe(false);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should wrap portrait index from the last position back to 0', fakeAsync((): void => {
|
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
|
||||||
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
|
comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1);
|
||||||
const zone = TestBed.inject(NgZone);
|
const zone = TestBed.inject(NgZone);
|
||||||
zone.runOutsideAngular((): void => {
|
zone.runOutsideAngular((): void => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
tick(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(0);
|
expect(comp.currentPortraitHighlightIndex()).toBe(0);
|
||||||
tick(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bio toggle ─────────────────────────────────────────────────────────────
|
// ── Bio toggle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('toggleBio', (): void => {
|
describe('toggleBio', (): void => {
|
||||||
it('should mount bio immediately on opening', fakeAsync((): void => {
|
it('should mount bio immediately on opening', async (): Promise<void> => {
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
expect(comp.isLongBioMounted).toBeTrue();
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
tick(100);
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should set isLongBioShown to true after the double-rAF delay', fakeAsync((): void => {
|
it('should set isLongBioShown to true after the double-rAF delay', async (): Promise<void> => {
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
tick(50); // allow both nested requestAnimationFrame calls to execute
|
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioShown).toBeTrue();
|
expect(comp.isLongBioShown()).toBe(true);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should set isLongBioShown to false immediately on close', fakeAsync((): void => {
|
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
|
||||||
// Open first
|
// Open first
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
tick(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
// Close
|
// Close
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioShown).toBeFalse();
|
expect(comp.isLongBioShown()).toBe(false);
|
||||||
tick(300);
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should keep isLongBioMounted true during the 300 ms close animation', fakeAsync((): void => {
|
it('should keep isLongBioMounted true during the 300 ms close animation', async (): Promise<void> => {
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
tick(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioMounted).toBeTrue();
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
|
|
||||||
tick(299);
|
await vi.advanceTimersByTimeAsync(299);
|
||||||
expect(comp.isLongBioMounted).toBeTrue();
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
|
|
||||||
tick(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioMounted).toBeFalse();
|
expect(comp.isLongBioMounted()).toBe(false);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should cancel pending close timeout when re-opened during animation', fakeAsync((): void => {
|
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
tick(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
comp.toggleBio(); // start closing
|
comp.toggleBio(); // start closing
|
||||||
tick(150); // halfway through 300 ms
|
await vi.advanceTimersByTimeAsync(150); // halfway through 300 ms
|
||||||
|
|
||||||
comp.toggleBio(); // re-open before unmount
|
comp.toggleBio(); // re-open before unmount
|
||||||
tick(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(comp.isLongBioMounted).toBeTrue();
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
expect(comp.isLongBioShown).toBeTrue();
|
expect(comp.isLongBioShown()).toBe(true);
|
||||||
|
|
||||||
tick(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Scroll helpers ─────────────────────────────────────────────────────────
|
// ── Scroll helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('scroll helpers', (): void => {
|
describe('scroll helpers', (): void => {
|
||||||
let scrollSpy: jasmine.Spy;
|
let scrollSpy: Mock;
|
||||||
|
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
scrollSpy = spyOn(window, 'scrollTo');
|
scrollSpy = vi.spyOn(window, 'scrollTo').mockReturnValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call window.scrollTo when scrolling to the about section', (): void => {
|
it('should call window.scrollTo when scrolling to the about section', (): void => {
|
||||||
@@ -349,20 +397,18 @@ describe('HomeComponent', (): void => {
|
|||||||
// ── Lifecycle cleanup ──────────────────────────────────────────────────────
|
// ── Lifecycle cleanup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('ngOnDestroy', (): void => {
|
describe('ngOnDestroy', (): void => {
|
||||||
it('should clear pending portrait switch timers on destroy', fakeAsync((): void => {
|
it('should clear pending portrait switch timers on destroy', async (): Promise<void> => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
const clearSpy = spyOn(window, 'clearTimeout').and.callThrough();
|
const clearSpy = vi.spyOn(window, 'clearTimeout');
|
||||||
|
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
|
|
||||||
expect(clearSpy).toHaveBeenCalled();
|
expect(clearSpy).toHaveBeenCalled();
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
it('should not throw when destroyed', fakeAsync((): void => {
|
it('should not throw when destroyed', async (): Promise<void> => {
|
||||||
expect((): void => fixture.destroy()).not.toThrow();
|
expect((): void => fixture.destroy()).not.toThrow();
|
||||||
discardPeriodicTasks();
|
});
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Template rendering ─────────────────────────────────────────────────────
|
// ── Template rendering ─────────────────────────────────────────────────────
|
||||||
@@ -385,7 +431,13 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(h1?.textContent).toContain(global.lastname);
|
expect(h1?.textContent).toContain(global.lastname);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the bio label from the current bioTextsList entry', (): void => {
|
it('should link the skills box to the LinkedIn skills page', (): void => {
|
||||||
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
|
const link = el.querySelector<HTMLAnchorElement>('a.hero-meta');
|
||||||
|
expect(link?.getAttribute('href')).toBe(global.linkedinSkillsUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the skills label above the rotating skill', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.querySelector('.meta-label')).not.toBeNull();
|
expect(el.querySelector('.meta-label')).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -411,6 +463,17 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(entries.length).toBe(enLanguage.projects.length);
|
expect(entries.length).toBe(enLanguage.projects.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep the arrow on the same line as the last title word', (): void => {
|
||||||
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
|
const tails = Array.from(el.querySelectorAll('.project-title-tail'));
|
||||||
|
expect(tails.map((tail): string => tail.textContent?.trim() ?? '')).toEqual(
|
||||||
|
enLanguage.projects.map((project): string => project.title.split(' ').at(-1) ?? ''),
|
||||||
|
);
|
||||||
|
tails.forEach((tail): void => {
|
||||||
|
expect(tail.querySelector('.project-arrow')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should render all project titles', (): void => {
|
it('should render all project titles', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
enLanguage.projects.forEach((project): void => {
|
enLanguage.projects.forEach((project): void => {
|
||||||
@@ -430,7 +493,7 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
describe('Performance', (): void => {
|
describe('Performance', (): void => {
|
||||||
it('should keep scroll effects enabled on desktop pointers when motion is allowed', (): void => {
|
it('should keep scroll effects enabled on desktop pointers when motion is allowed', (): void => {
|
||||||
spyOn(window, 'matchMedia').and.callFake(
|
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||||
(query: string): MediaQueryList =>
|
(query: string): MediaQueryList =>
|
||||||
({
|
({
|
||||||
matches:
|
matches:
|
||||||
@@ -439,29 +502,29 @@ describe('HomeComponent', (): void => {
|
|||||||
}) as MediaQueryList,
|
}) as MediaQueryList,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(comp.shouldEnableScrollEffects()).toBeTrue();
|
expect(comp.shouldEnableScrollEffects()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should keep scroll effects enabled on coarse touch pointers when motion is allowed', (): void => {
|
it('should keep scroll effects enabled on coarse touch pointers when motion is allowed', (): void => {
|
||||||
spyOn(window, 'matchMedia').and.callFake(
|
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||||
(query: string): MediaQueryList =>
|
(query: string): MediaQueryList =>
|
||||||
({
|
({
|
||||||
matches: query === '(hover: none) and (pointer: coarse)',
|
matches: query === '(hover: none) and (pointer: coarse)',
|
||||||
}) as MediaQueryList,
|
}) as MediaQueryList,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(comp.shouldEnableScrollEffects()).toBeTrue();
|
expect(comp.shouldEnableScrollEffects()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable scroll effects when reduced motion is requested', (): void => {
|
it('should disable scroll effects when reduced motion is requested', (): void => {
|
||||||
spyOn(window, 'matchMedia').and.callFake(
|
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||||
(query: string): MediaQueryList =>
|
(query: string): MediaQueryList =>
|
||||||
({
|
({
|
||||||
matches: query === '(prefers-reduced-motion: reduce)',
|
matches: query === '(prefers-reduced-motion: reduce)',
|
||||||
}) as MediaQueryList,
|
}) as MediaQueryList,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(comp.shouldEnableScrollEffects()).toBeFalse();
|
expect(comp.shouldEnableScrollEffects()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create and run initial change detection within 200 ms', (): void => {
|
it('should create and run initial change detection within 200 ms', (): void => {
|
||||||
@@ -480,57 +543,22 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(performance.now() - start).toBeLessThan(100);
|
expect(performance.now() - start).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate rapid schedule calls to a single requestAnimationFrame', fakeAsync((): void => {
|
it('should batch all scroll effects into a single requestAnimationFrame', async (): Promise<void> => {
|
||||||
// The RAF registered during beforeEach's ngOnInit lives outside this fakeAsync zone.
|
// The RAF registered after the first render is still pending under fake timers.
|
||||||
// Resetting the ID to null makes the schedule function treat the slot as free.
|
// Resetting the ID to null makes the schedule function treat the slot as free.
|
||||||
comp.scrollAnimationFrameId = null;
|
comp.scrollEffectsAnimationFrameId = null;
|
||||||
|
|
||||||
let rafCallCount = 0;
|
let rafCallCount = 0;
|
||||||
spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
|
||||||
rafCallCount++;
|
rafCallCount++;
|
||||||
return rafCallCount; // returns non-null so subsequent calls deduplicate
|
return rafCallCount; // returns non-null so subsequent calls deduplicate
|
||||||
});
|
});
|
||||||
|
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
|
|
||||||
expect(rafCallCount).toBe(1);
|
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();
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+106
-191
@@ -1,70 +1,52 @@
|
|||||||
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
|
import type { OnDestroy } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { filter, interval } from 'rxjs';
|
||||||
import { interval } from 'rxjs';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import type {
|
|
||||||
LanguageBioTextEntry,
|
|
||||||
LanguagePortraitHighlight,
|
|
||||||
LanguageProject,
|
|
||||||
} from '../../languages/language.types';
|
|
||||||
import { FooterComponent } from '../footer/footer.component';
|
import { FooterComponent } from '../footer/footer.component';
|
||||||
import { LanguageService } from '../services/language.service';
|
import { LanguageService } from '../services/language.service';
|
||||||
import { IconComponent } from '../shared/icon/icon.component';
|
import { IconComponent } from '../shared/icon/icon.component';
|
||||||
import {
|
import {
|
||||||
arrowDownIcon,
|
arrowDownIcon,
|
||||||
arrowRightIcon,
|
arrowRightIcon,
|
||||||
discordIcon,
|
|
||||||
envelopeIcon,
|
envelopeIcon,
|
||||||
githubIcon,
|
giteaIcon,
|
||||||
linkedinIcon,
|
linkedinIcon,
|
||||||
phoneIcon,
|
phoneIcon,
|
||||||
|
signalIcon,
|
||||||
} from '../shared/icon/icons';
|
} from '../shared/icon/icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FooterComponent, IconComponent, NgOptimizedImage],
|
imports: [FooterComponent, IconComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class HomeComponent implements OnDestroy {
|
||||||
|
private static readonly bioTickerIntervalMs = 1250;
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly zone = inject(NgZone);
|
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
protected readonly content = this.languageService.content;
|
protected readonly content = this.languageService.content;
|
||||||
|
|
||||||
private scrollAnimationFrameId: number | null = null;
|
private scrollEffectsAnimationFrameId: number | null = null;
|
||||||
private projectEntryAnimationFrameId: number | null = null;
|
private readonly scheduleScrollEffectsUpdate: () => void = (): void => {
|
||||||
private aboutSectionAnimationFrameId: number | null = null;
|
if (this.scrollEffectsAnimationFrameId !== null) return;
|
||||||
private readonly scheduleNameGradientUpdate: () => void = (): void => {
|
this.scrollEffectsAnimationFrameId = window.requestAnimationFrame((): void => {
|
||||||
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
|
this.scrollEffectsAnimationFrameId = null;
|
||||||
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
|
|
||||||
this.scrollAnimationFrameId = null;
|
|
||||||
this.updateNameGradientProgress();
|
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();
|
this.updateAboutSectionScrollProgress();
|
||||||
|
this.updateProjectEntryRevealProgress();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,66 +57,47 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
protected readonly arrowDownIcon = arrowDownIcon;
|
protected readonly arrowDownIcon = arrowDownIcon;
|
||||||
protected readonly envelopeIcon = envelopeIcon;
|
protected readonly envelopeIcon = envelopeIcon;
|
||||||
protected readonly phoneIcon = phoneIcon;
|
protected readonly phoneIcon = phoneIcon;
|
||||||
protected readonly discordIcon = discordIcon;
|
protected readonly signalIcon = signalIcon;
|
||||||
protected readonly githubIcon = githubIcon;
|
protected readonly giteaIcon = giteaIcon;
|
||||||
protected readonly linkedinIcon = linkedinIcon;
|
protected readonly linkedinIcon = linkedinIcon;
|
||||||
|
|
||||||
private readonly isLongBioShownState = signal(false);
|
protected readonly isLongBioShown = signal(false);
|
||||||
private readonly isLongBioMountedState = signal(false);
|
protected readonly isLongBioMounted = signal(false);
|
||||||
private readonly currentPortraitHighlightIndexState = signal(0);
|
protected readonly currentPortraitHighlightIndex = signal(0);
|
||||||
private readonly isPortraitSwitchingState = signal(false);
|
protected readonly isPortraitSwitching = signal(false);
|
||||||
private readonly currentIndexState = signal(0);
|
protected readonly currentIndex = signal(0);
|
||||||
|
protected readonly isBioTickerPaused = signal(false);
|
||||||
private bioHideTimeoutId: number | null = null;
|
private bioHideTimeoutId: number | null = null;
|
||||||
private portraitSwitchTimeoutId: number | null = null;
|
private portraitSwitchTimeoutId: number | null = null;
|
||||||
private portraitSwitchDoneTimeoutId: number | null = null;
|
private portraitSwitchDoneTimeoutId: number | null = null;
|
||||||
|
|
||||||
protected get isLongBioShown(): boolean {
|
protected readonly portraitHighlights = computed(() => this.content().portraitHighlights);
|
||||||
return this.isLongBioShownState();
|
/** Splits off the last title word so the arrow stays glued to it and never wraps alone. */
|
||||||
}
|
protected readonly projects = computed(() =>
|
||||||
protected set isLongBioShown(value: boolean) {
|
this.content().projects.map((project) => {
|
||||||
this.isLongBioShownState.set(value);
|
const tailStart = project.title.lastIndexOf(' ') + 1;
|
||||||
}
|
return {
|
||||||
protected get isLongBioMounted(): boolean {
|
...project,
|
||||||
return this.isLongBioMountedState();
|
titleHead: project.title.slice(0, tailStart),
|
||||||
}
|
titleTail: project.title.slice(tailStart),
|
||||||
protected set isLongBioMounted(value: boolean) {
|
};
|
||||||
this.isLongBioMountedState.set(value);
|
}),
|
||||||
}
|
);
|
||||||
protected get currentPortraitHighlightIndex(): number {
|
protected readonly currentPortraitHighlight = computed(
|
||||||
return this.currentPortraitHighlightIndexState();
|
() => this.portraitHighlights()[this.currentPortraitHighlightIndex()] ?? null,
|
||||||
}
|
);
|
||||||
protected set currentPortraitHighlightIndex(value: number) {
|
protected readonly currentBioText = computed(
|
||||||
this.currentPortraitHighlightIndexState.set(value);
|
(): string => this.content().bioTextsList[this.currentIndex()] ?? '',
|
||||||
}
|
);
|
||||||
protected get isPortraitSwitching(): boolean {
|
|
||||||
return this.isPortraitSwitchingState();
|
private readonly startBrowserEffects = afterNextRender((): void => {
|
||||||
}
|
this.preloadPortraitHighlights();
|
||||||
protected set isPortraitSwitching(value: boolean) {
|
if (!this.shouldEnableScrollEffects()) return;
|
||||||
this.isPortraitSwitchingState.set(value);
|
this.startBioTicker();
|
||||||
}
|
window.addEventListener('scroll', this.scheduleScrollEffectsUpdate, { passive: true });
|
||||||
protected get currentIndex(): number {
|
window.addEventListener('resize', this.scheduleScrollEffectsUpdate, { passive: true });
|
||||||
return this.currentIndexState();
|
this.scheduleScrollEffectsUpdate();
|
||||||
}
|
});
|
||||||
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 interpolate(text: string): string {
|
protected interpolate(text: string): string {
|
||||||
return text
|
return text
|
||||||
@@ -142,78 +105,48 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
.replaceAll('{{age}}', String(this.age));
|
.replaceAll('{{age}}', String(this.age));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.zone.runOutsideAngular((): void => {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.zone.runOutsideAngular((): void => {
|
|
||||||
this.initProjectEntryRevealObserver();
|
|
||||||
this.initAboutSectionScrollAnimation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
if (this.bioHideTimeoutId !== null) {
|
if (this.bioHideTimeoutId !== null) {
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
window.clearTimeout(this.bioHideTimeoutId);
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
this.clearPortraitSwitchTimeouts();
|
this.clearPortraitSwitchTimeouts();
|
||||||
this.destroyNameGradientScrollAnimation();
|
this.destroyScrollEffects();
|
||||||
this.destroyProjectEntryRevealObserver();
|
|
||||||
this.destroyAboutSectionScrollAnimation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggleBio(): void {
|
protected toggleBio(): void {
|
||||||
if (this.isLongBioShown) {
|
if (this.isLongBioShown()) {
|
||||||
this.isLongBioShown = false;
|
this.isLongBioShown.set(false);
|
||||||
if (typeof window !== 'undefined') {
|
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
|
||||||
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
|
this.bioHideTimeoutId = window.setTimeout((): void => {
|
||||||
this.bioHideTimeoutId = window.setTimeout((): void => {
|
this.isLongBioMounted.set(false);
|
||||||
this.isLongBioMounted = false;
|
this.bioHideTimeoutId = null;
|
||||||
this.bioHideTimeoutId = null;
|
}, 300);
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
this.isLongBioMounted = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
|
if (this.bioHideTimeoutId !== null) {
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
window.clearTimeout(this.bioHideTimeoutId);
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
this.isLongBioMounted = true;
|
this.isLongBioMounted.set(true);
|
||||||
if (typeof window !== 'undefined') {
|
window.requestAnimationFrame((): void => {
|
||||||
window.requestAnimationFrame((): void => {
|
window.requestAnimationFrame((): void => {
|
||||||
window.requestAnimationFrame((): void => {
|
this.isLongBioShown.set(true);
|
||||||
this.isLongBioShown = true;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
});
|
||||||
this.isLongBioShown = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
protected showNextPortraitHighlight(): void {
|
protected showNextPortraitHighlight(): void {
|
||||||
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
|
if (this.portraitHighlights().length <= 1 || this.isPortraitSwitching()) return;
|
||||||
this.isPortraitSwitching = true;
|
this.isPortraitSwitching.set(true);
|
||||||
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
|
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
|
||||||
this.currentPortraitHighlightIndex =
|
this.currentPortraitHighlightIndex.update(
|
||||||
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
|
(index: number): number => (index + 1) % this.portraitHighlights().length,
|
||||||
|
);
|
||||||
this.portraitSwitchTimeoutId = null;
|
this.portraitSwitchTimeoutId = null;
|
||||||
}, 125);
|
}, 125);
|
||||||
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
|
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
|
||||||
this.isPortraitSwitching = false;
|
this.isPortraitSwitching.set(false);
|
||||||
this.portraitSwitchDoneTimeoutId = null;
|
this.portraitSwitchDoneTimeoutId = null;
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -237,62 +170,47 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return !prefersReducedMotion;
|
return !prefersReducedMotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
private initNameGradientScrollAnimation(): void {
|
/** Loads the other highlight images at low priority so a click swaps them without a visible delay. */
|
||||||
if (!this.shouldEnableScrollEffects()) return;
|
private preloadPortraitHighlights(): void {
|
||||||
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
|
const current = this.currentPortraitHighlight();
|
||||||
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
|
for (const highlight of this.portraitHighlights()) {
|
||||||
this.scheduleNameGradientUpdate();
|
if (highlight === current) continue;
|
||||||
}
|
const image = new Image();
|
||||||
private initProjectEntryRevealObserver(): void {
|
image.fetchPriority = 'low';
|
||||||
if (!this.shouldEnableScrollEffects()) return;
|
image.sizes = highlight.imageSizes;
|
||||||
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
image.srcset = highlight.imageSrcset;
|
||||||
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
image.src = highlight.image;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBioTicker(): void {
|
||||||
|
interval(HomeComponent.bioTickerIntervalMs)
|
||||||
|
.pipe(
|
||||||
|
filter((): boolean => !this.isBioTickerPaused()),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((): void => {
|
||||||
|
const length = this.content().bioTextsList.length;
|
||||||
|
this.currentIndex.update((index: number): number =>
|
||||||
|
length > 0 ? (index + 1) % length : 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyScrollEffects(): void {
|
||||||
|
window.removeEventListener('scroll', this.scheduleScrollEffectsUpdate);
|
||||||
|
window.removeEventListener('resize', this.scheduleScrollEffectsUpdate);
|
||||||
|
if (this.scrollEffectsAnimationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(this.scrollEffectsAnimationFrameId);
|
||||||
|
this.scrollEffectsAnimationFrameId = null;
|
||||||
|
}
|
||||||
|
document.documentElement.style.removeProperty('--name-pride-progress');
|
||||||
|
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
|
||||||
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
|
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
|
||||||
entry.style.removeProperty('--project-entry-progress');
|
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 {
|
private clearPortraitSwitchTimeouts(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
if (this.portraitSwitchTimeoutId !== null) {
|
if (this.portraitSwitchTimeoutId !== null) {
|
||||||
window.clearTimeout(this.portraitSwitchTimeoutId);
|
window.clearTimeout(this.portraitSwitchTimeoutId);
|
||||||
this.portraitSwitchTimeoutId = null;
|
this.portraitSwitchTimeoutId = null;
|
||||||
@@ -303,7 +221,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private updateNameGradientProgress(): void {
|
private updateNameGradientProgress(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const root: HTMLElement = document.documentElement;
|
const root: HTMLElement = document.documentElement;
|
||||||
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
||||||
const portfolioTriggerY: number =
|
const portfolioTriggerY: number =
|
||||||
@@ -324,7 +241,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
||||||
}
|
}
|
||||||
private updateAboutSectionScrollProgress(): void {
|
private updateAboutSectionScrollProgress(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const aboutSection: HTMLElement | null = document.getElementById('about');
|
const aboutSection: HTMLElement | null = document.getElementById('about');
|
||||||
if (aboutSection === null) return;
|
if (aboutSection === null) return;
|
||||||
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
||||||
@@ -337,7 +253,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
||||||
}
|
}
|
||||||
private updateProjectEntryRevealProgress(): void {
|
private updateProjectEntryRevealProgress(): void {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
||||||
const viewportHeight: number = window.innerHeight || 1;
|
const viewportHeight: number = window.innerHeight || 1;
|
||||||
const startY: number = viewportHeight * 0.98;
|
const startY: number = viewportHeight * 0.98;
|
||||||
|
|||||||
@@ -17,10 +17,8 @@
|
|||||||
<div class="portrait-highlight legal-contact-box">
|
<div class="portrait-highlight legal-contact-box">
|
||||||
<p class="hero-contact-title">{{ content().imprint.provider }}</p>
|
<p class="hero-contact-title">{{ content().imprint.provider }}</p>
|
||||||
<p class="legal-provider-name">{{ global.firstname }} {{ global.lastname }}</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.street }} {{ global.address.houseNumber }}</p>
|
||||||
<p>{{ global.address.zip }} {{ global.address.city }}</p>
|
<p>{{ global.address.zip }} {{ global.address.city }}</p>
|
||||||
<p>{{ global.address.state }}</p>
|
|
||||||
<p>{{ global.address.country }}</p>
|
<p>{{ global.address.country }}</p>
|
||||||
<hr />
|
<hr />
|
||||||
<p class="hero-contact-title">{{ content().imprint.directContact }}</p>
|
<p class="hero-contact-title">{{ content().imprint.directContact }}</p>
|
||||||
@@ -37,20 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading compact">
|
<div class="section-heading compact">
|
||||||
<p class="eyebrow">{{ content().imprint.privacyEyebrow }}</p>
|
<p class="eyebrow">{{ content().imprint.privacyEyebrow }}</p>
|
||||||
@@ -58,17 +42,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>{{ bind(content().imprint.privacyLeftParagraphs[0]) }}</p>
|
<p>{{ content().imprint.privacyLeft }}</p>
|
||||||
<p>{{ content().imprint.privacyLeftParagraphs[1] }}</p>
|
|
||||||
<p>{{ content().imprint.privacyLeftParagraphs[2] }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<p>{{ content().imprint.privacyRightParagraphs[0] }}</p>
|
<p>{{ content().imprint.privacyRight }}</p>
|
||||||
<p>{{ content().imprint.privacyRightParagraphs[1] }}</p>
|
|
||||||
<p>{{ content().imprint.privacyRightParagraphs[2] }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="story-section panel legal-section">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading compact">
|
<div class="section-heading compact">
|
||||||
<p class="eyebrow">{{ content().imprint.rightsEyebrow }}</p>
|
<p class="eyebrow">{{ content().imprint.rightsEyebrow }}</p>
|
||||||
@@ -90,7 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>{{ content().imprint.copyrightLeftParagraphs[0] }}</p>
|
<p>{{ bind(content().imprint.copyrightLeftParagraphs[0]) }}</p>
|
||||||
<p>{{ content().imprint.copyrightLeftParagraphs[1] }}</p>
|
<p>{{ content().imprint.copyrightLeftParagraphs[1] }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
@@ -101,4 +82,4 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<app-footer ngSkipHydration></app-footer>
|
<app-footer [showImprintLink]="false"></app-footer>
|
||||||
@@ -133,7 +133,7 @@ describe('ImprintComponent', (): void => {
|
|||||||
it('should render an "Imprint & Privacy" heading', (): void => {
|
it('should render an "Imprint & Privacy" heading', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
const headings = Array.from(el.querySelectorAll('h1'));
|
const headings = Array.from(el.querySelectorAll('h1'));
|
||||||
expect(headings.some((h): boolean => h.textContent?.includes('Imprint') === true)).toBeTrue();
|
expect(headings.some((h): boolean => h.textContent?.includes('Imprint') === true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain a back link to the portfolio root', (): void => {
|
it('should contain a back link to the portfolio root', (): void => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LanguagePreferenceService } from './language-preference.service';
|
||||||
|
|
||||||
|
describe('LanguagePreferenceService', (): void => {
|
||||||
|
let service: LanguagePreferenceService;
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
localStorage.clear();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
service = TestBed.inject(LanguagePreferenceService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to English for unsupported browser languages', (): void => {
|
||||||
|
vi.spyOn(navigator, 'language', 'get').mockReturnValue('fr-FR');
|
||||||
|
expect(service.initialize()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the browser language on the first visit', (): void => {
|
||||||
|
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT');
|
||||||
|
expect(service.initialize()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer a stored choice over the browser language', (): void => {
|
||||||
|
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT');
|
||||||
|
localStorage.setItem('portfolio-language', 'en');
|
||||||
|
expect(service.initialize()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer the lang query parameter over everything else', (): void => {
|
||||||
|
localStorage.setItem('portfolio-language', 'en');
|
||||||
|
window.history.replaceState(null, '', '/?lang=de');
|
||||||
|
expect(service.initialize()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore an unsupported lang query parameter', (): void => {
|
||||||
|
vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
|
||||||
|
window.history.replaceState(null, '', '/?lang=fr');
|
||||||
|
expect(service.initialize()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist a confirmed language', (): void => {
|
||||||
|
service.confirmLanguage('de');
|
||||||
|
expect(service.languageCode()).toBe('de');
|
||||||
|
expect(localStorage.getItem('portfolio-language')).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,31 +2,35 @@ import { Injectable, signal } from '@angular/core';
|
|||||||
|
|
||||||
import type { LanguageCode } from '../../languages/language.types';
|
import type { LanguageCode } from '../../languages/language.types';
|
||||||
|
|
||||||
|
const isLanguageCode = (value: string | null): value is LanguageCode =>
|
||||||
|
value === 'en' || value === 'de';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LanguagePreferenceService {
|
export class LanguagePreferenceService {
|
||||||
private static readonly storageKey = 'portfolio-language';
|
private static readonly storageKey = 'portfolio-language';
|
||||||
private readonly currentLanguageCode = signal<LanguageCode>('en');
|
private readonly currentLanguageCode = signal<LanguageCode>('en');
|
||||||
private readonly languageConfirmed = signal(true);
|
|
||||||
|
|
||||||
readonly languageCode = this.currentLanguageCode.asReadonly();
|
readonly languageCode = this.currentLanguageCode.asReadonly();
|
||||||
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
|
|
||||||
|
|
||||||
initializeFromStorage(): LanguageCode {
|
/** Picks the language from `?lang=`, then the stored choice, then the browser language. */
|
||||||
|
initialize(): LanguageCode {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return this.currentLanguageCode();
|
return this.currentLanguageCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedLanguage = window.localStorage.getItem(LanguagePreferenceService.storageKey);
|
const candidates = [
|
||||||
if (storedLanguage === 'de' || storedLanguage === 'en') {
|
new URLSearchParams(window.location.search).get('lang'),
|
||||||
this.currentLanguageCode.set(storedLanguage);
|
window.localStorage.getItem(LanguagePreferenceService.storageKey),
|
||||||
}
|
window.navigator.language.slice(0, 2).toLowerCase(),
|
||||||
|
];
|
||||||
|
const language = candidates.find(isLanguageCode) ?? 'en';
|
||||||
|
this.currentLanguageCode.set(language);
|
||||||
|
|
||||||
return this.currentLanguageCode();
|
return language;
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmLanguage(code: LanguageCode): void {
|
confirmLanguage(code: LanguageCode): void {
|
||||||
this.currentLanguageCode.set(code);
|
this.currentLanguageCode.set(code);
|
||||||
this.languageConfirmed.set(true);
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(LanguagePreferenceService.storageKey, code);
|
window.localStorage.setItem(LanguagePreferenceService.storageKey, code);
|
||||||
|
|||||||
@@ -2,26 +2,15 @@ import { Injectable, computed, inject } from '@angular/core';
|
|||||||
|
|
||||||
import { deLanguage } from '../../languages/de';
|
import { deLanguage } from '../../languages/de';
|
||||||
import { enLanguage } from '../../languages/en';
|
import { enLanguage } from '../../languages/en';
|
||||||
import type { LanguageCode, LanguagePack } from '../../languages/language.types';
|
import type { LanguagePack } from '../../languages/language.types';
|
||||||
import { LanguagePreferenceService } from './language-preference.service';
|
import { LanguagePreferenceService } from './language-preference.service';
|
||||||
|
|
||||||
|
/** Page content for the lazy-loaded routes; kept apart from the shell texts to stay out of the initial bundle. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LanguageService {
|
export class LanguageService {
|
||||||
private readonly languagePreferenceService = inject(LanguagePreferenceService);
|
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
|
||||||
|
|
||||||
readonly languageCode = this.languagePreferenceService.languageCode;
|
readonly content = computed<LanguagePack>(() =>
|
||||||
readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed;
|
this.languageCode() === 'de' ? deLanguage : enLanguage,
|
||||||
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,79 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
import { LanguagePreferenceService } from './language-preference.service';
|
||||||
|
import { SeoService } from './seo.service';
|
||||||
|
|
||||||
|
@Component({ template: '' })
|
||||||
|
class EmptyComponent {}
|
||||||
|
|
||||||
|
describe('SeoService', (): void => {
|
||||||
|
const getHref = (selector: string): string | null =>
|
||||||
|
document.head.querySelector(selector)?.getAttribute('href') ?? null;
|
||||||
|
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
localStorage.clear();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([
|
||||||
|
{ path: '', component: EmptyComponent },
|
||||||
|
{ path: 'imprint', component: EmptyComponent },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
TestBed.inject(SeoService);
|
||||||
|
await TestBed.inject(Router).navigateByUrl('/');
|
||||||
|
TestBed.tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe the English start page by default', (): void => {
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
expect(document.title).toContain('Julian Lechner');
|
||||||
|
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/');
|
||||||
|
expect(getHref('link[hreflang="de"]')).toBe('https://fraujulian.xyz/?lang=de');
|
||||||
|
expect(getHref('link[hreflang="x-default"]')).toBe('https://fraujulian.xyz/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch language, title and canonical URL together', async (): Promise<void> => {
|
||||||
|
TestBed.inject(LanguagePreferenceService).confirmLanguage('de');
|
||||||
|
await TestBed.inject(Router).navigateByUrl('/imprint');
|
||||||
|
TestBed.tick();
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('de');
|
||||||
|
expect(document.title).toContain('Impressum');
|
||||||
|
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/imprint?lang=de');
|
||||||
|
expect(document.head.querySelector('meta[property="og:locale"]')?.getAttribute('content')).toBe(
|
||||||
|
'de_AT',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('home title', (): void => {
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([{ path: '', component: EmptyComponent }])],
|
||||||
|
});
|
||||||
|
TestBed.inject(SeoService);
|
||||||
|
await TestBed.inject(Router).navigateByUrl('/');
|
||||||
|
TestBed.tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should alternate between name and role every 3.5 seconds', (): void => {
|
||||||
|
expect(document.title).toBe('Julian Lechner');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3500);
|
||||||
|
TestBed.tick();
|
||||||
|
expect(document.title).toBe('Full Stack Developer');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3500);
|
||||||
|
TestBed.tick();
|
||||||
|
expect(document.title).toBe('Julian Lechner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, effect, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { Meta, Title } from '@angular/platform-browser';
|
||||||
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { filter, interval, map } from 'rxjs';
|
||||||
|
|
||||||
|
import { global } from '../../global';
|
||||||
|
import type { LanguageCode } from '../../languages/language.types';
|
||||||
|
import { LanguagePreferenceService } from './language-preference.service';
|
||||||
|
import { ShellLanguageService } from './shell-language.service';
|
||||||
|
|
||||||
|
const languageCodes: readonly LanguageCode[] = ['en', 'de'];
|
||||||
|
const ogLocales: Readonly<Record<LanguageCode, string>> = { en: 'en_US', de: 'de_AT' };
|
||||||
|
const homeTitles: readonly string[] = ['Julian Lechner', 'Full Stack Developer'];
|
||||||
|
const homeTitleSwitchMs = 3500;
|
||||||
|
|
||||||
|
/** Keeps `<html lang>`, title, description, canonical, hreflang and Open Graph tags in sync. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SeoService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly title = inject(Title);
|
||||||
|
private readonly meta = inject(Meta);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
|
||||||
|
private readonly content = inject(ShellLanguageService).content;
|
||||||
|
private readonly path = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
map((event: NavigationEnd): string => event.urlAfterRedirects.split(/[?#]/u)[0] ?? '/'),
|
||||||
|
),
|
||||||
|
{ initialValue: '/' },
|
||||||
|
);
|
||||||
|
private readonly titleTick = toSignal(interval(homeTitleSwitchMs), { initialValue: -1 });
|
||||||
|
|
||||||
|
private readonly syncHead = effect((): void => {
|
||||||
|
this.update(this.languageCode(), this.path());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The start page title alternates in the browser tab; other pages keep a fixed title. */
|
||||||
|
private readonly syncTitle = effect((): void => {
|
||||||
|
const homeTitle = homeTitles[(this.titleTick() + 1) % homeTitles.length] ?? global.firstname;
|
||||||
|
this.title.setTitle(this.path() === '/imprint' ? this.content().meta.imprintTitle : homeTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
private update(language: LanguageCode, path: string): void {
|
||||||
|
const { meta } = this.content();
|
||||||
|
const pageTitle = path === '/imprint' ? meta.imprintTitle : meta.homeTitle;
|
||||||
|
const pageUrl = this.getPageUrl(path, language);
|
||||||
|
|
||||||
|
this.document.documentElement.lang = language;
|
||||||
|
this.meta.updateTag({ name: 'description', content: meta.description });
|
||||||
|
this.meta.updateTag({ property: 'og:title', content: pageTitle });
|
||||||
|
this.meta.updateTag({ property: 'og:description', content: meta.description });
|
||||||
|
this.meta.updateTag({ property: 'og:url', content: pageUrl });
|
||||||
|
this.meta.updateTag({ property: 'og:locale', content: ogLocales[language] });
|
||||||
|
this.setLink('link[rel="canonical"]', { rel: 'canonical', href: pageUrl });
|
||||||
|
|
||||||
|
for (const code of languageCodes) {
|
||||||
|
this.setLink(`link[rel="alternate"][hreflang="${code}"]`, {
|
||||||
|
rel: 'alternate',
|
||||||
|
hreflang: code,
|
||||||
|
href: this.getPageUrl(path, code),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.setLink('link[rel="alternate"][hreflang="x-default"]', {
|
||||||
|
rel: 'alternate',
|
||||||
|
hreflang: 'x-default',
|
||||||
|
href: this.getPageUrl(path, 'en'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English is the default and lives on the plain URL; other languages use `?lang=`. */
|
||||||
|
private getPageUrl(path: string, language: LanguageCode): string {
|
||||||
|
const pageUrl = `${global.siteUrl}${path}`;
|
||||||
|
return language === 'en' ? pageUrl : `${pageUrl}?lang=${language}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLink(selector: string, attributes: Readonly<Record<string, string>>): void {
|
||||||
|
let link = this.document.head.querySelector<HTMLLinkElement>(selector);
|
||||||
|
if (link === null) {
|
||||||
|
link = this.document.createElement('link');
|
||||||
|
this.document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
link.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,10 @@ import { LanguagePreferenceService } from './language-preference.service';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ShellLanguageService {
|
export class ShellLanguageService {
|
||||||
private readonly languagePreferenceService = inject(LanguagePreferenceService);
|
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
|
||||||
|
|
||||||
readonly languageCode = this.languagePreferenceService.languageCode;
|
|
||||||
readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed;
|
|
||||||
readonly content = computed<LanguageShellPack>(() => this.getPack(this.languageCode()));
|
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 {
|
getPack(code: LanguageCode): LanguageShellPack {
|
||||||
return code === 'de' ? deShellLanguage : enShellLanguage;
|
return code === 'de' ? deShellLanguage : enShellLanguage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ export const arrowRightIcon: SvgIcon = {
|
|||||||
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',
|
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 = {
|
// Signal logo from Simple Icons (CC0 1.0).
|
||||||
viewBox: '0 0 576 512',
|
export const signalIcon: SvgIcon = {
|
||||||
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',
|
viewBox: '0 0 24 24',
|
||||||
|
path: 'M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const envelopeIcon: SvgIcon = {
|
export const envelopeIcon: SvgIcon = {
|
||||||
@@ -25,9 +26,10 @@ export const envelopeIcon: SvgIcon = {
|
|||||||
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',
|
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 = {
|
// Gitea logo from Simple Icons (CC0 1.0).
|
||||||
viewBox: '0 0 512 512',
|
export const giteaIcon: SvgIcon = {
|
||||||
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',
|
viewBox: '0 0 24 24',
|
||||||
|
path: 'M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const linkedinIcon: SvgIcon = {
|
export const linkedinIcon: SvgIcon = {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 5.0 MiB |
+5
-2
@@ -3,8 +3,12 @@ import type { Global } from './global.types';
|
|||||||
export const global: Global = {
|
export const global: Global = {
|
||||||
firstname: 'Julian',
|
firstname: 'Julian',
|
||||||
lastname: 'Lechner',
|
lastname: 'Lechner',
|
||||||
contactMail: 'fraujulian@lechner.top',
|
siteUrl: 'https://fraujulian.xyz',
|
||||||
|
contactMail: 'julian@lechner.top',
|
||||||
abuseMail: 'abuse@lechner-systems.at',
|
abuseMail: 'abuse@lechner-systems.at',
|
||||||
|
linkedinSkillsUrl: 'https://www.linkedin.com/in/julianlechner2009/details/skills/',
|
||||||
|
signalUrl:
|
||||||
|
'https://signal.me/#eu/4c3PvAZdPSjVHLJ1zJJ6HChUE_T_HTDyEraQMwK6TRNyQSZCs5ZK-KKS00T9na-0',
|
||||||
contactPhone: '+43 660 9254001',
|
contactPhone: '+43 660 9254001',
|
||||||
hrefContactPhone: '+436609254001',
|
hrefContactPhone: '+436609254001',
|
||||||
birthdate: '2009-03-03',
|
birthdate: '2009-03-03',
|
||||||
@@ -13,7 +17,6 @@ export const global: Global = {
|
|||||||
houseNumber: 9,
|
houseNumber: 9,
|
||||||
zip: 3380,
|
zip: 3380,
|
||||||
city: 'Pöchlarn',
|
city: 'Pöchlarn',
|
||||||
state: 'Niederösterreich',
|
|
||||||
country: 'Österreich',
|
country: 'Österreich',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+3
-1
@@ -3,15 +3,17 @@ export interface ImprintData {
|
|||||||
houseNumber: number;
|
houseNumber: number;
|
||||||
zip: number;
|
zip: number;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Global {
|
export interface Global {
|
||||||
firstname: string;
|
firstname: string;
|
||||||
lastname: string;
|
lastname: string;
|
||||||
|
siteUrl: string;
|
||||||
contactMail: string;
|
contactMail: string;
|
||||||
abuseMail: string;
|
abuseMail: string;
|
||||||
|
linkedinSkillsUrl: string;
|
||||||
|
signalUrl: string;
|
||||||
contactPhone: string;
|
contactPhone: string;
|
||||||
hrefContactPhone: string;
|
hrefContactPhone: string;
|
||||||
birthdate: string;
|
birthdate: string;
|
||||||
|
|||||||
+43
-1
@@ -13,9 +13,51 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
|
|
||||||
<link rel="icon" type="image/webp" sizes="128x128" href="assets/optimized/portrait/me-128.webp" />
|
<link rel="icon" type="image/webp" sizes="128x128" href="assets/optimized/portrait/me-128.webp" />
|
||||||
<title>Lechner Julian</title>
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/optimized/icons/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="assets/optimized/icons/apple-touch-icon.png" />
|
||||||
|
<title>Julian Lechner</title>
|
||||||
|
<link rel="preload" as="image" fetchpriority="high" href="assets/optimized/background.webp" />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="image"
|
||||||
|
fetchpriority="high"
|
||||||
|
href="assets/optimized/portrait/me.webp"
|
||||||
|
imagesrcset="assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-384.webp 384w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w"
|
||||||
|
imagesizes="(max-width: 700px) calc(100vw - 4rem), (max-width: 900px) min(calc(100vw - 9rem), 560px), 320px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta property="og:type" content="profile" />
|
||||||
|
<meta property="og:site_name" content="Julian Lechner" />
|
||||||
|
<meta property="og:image" content="https://fraujulian.xyz/assets/optimized/icons/og-image.jpg" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Portrait of Julian Lechner" />
|
||||||
|
<meta property="profile:first_name" content="Julian" />
|
||||||
|
<meta property="profile:last_name" content="Lechner" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Julian Lechner",
|
||||||
|
"alternateName": "FrauJulian",
|
||||||
|
"url": "https://fraujulian.xyz/",
|
||||||
|
"image": "https://fraujulian.xyz/assets/optimized/icons/og-image.jpg",
|
||||||
|
"jobTitle": "Full-Stack Developer",
|
||||||
|
"address": { "@type": "PostalAddress", "addressCountry": "AT" },
|
||||||
|
"sameAs": ["https://git.lechner-systems.at/fraujulian", "https://www.linkedin.com/in/julianlechner2009/"]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<div class="page-shell footer-shell">
|
||||||
|
<div class="panel noscript-panel">
|
||||||
|
<p class="warning-text">Please enable JavaScript for the full experience. / Bitte aktiviere JavaScript, um alles zu sehen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+62
-82
@@ -1,18 +1,6 @@
|
|||||||
import type { LanguagePack } from './language.types';
|
import type { LanguagePack } from './language.types';
|
||||||
|
|
||||||
export const deLanguage: LanguagePack = {
|
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: {
|
home: {
|
||||||
portfolioEyebrow: 'Portfolio',
|
portfolioEyebrow: 'Portfolio',
|
||||||
heroIntro:
|
heroIntro:
|
||||||
@@ -41,79 +29,87 @@ export const deLanguage: LanguagePack = {
|
|||||||
getInTouch: 'Kontaktiere mich!',
|
getInTouch: 'Kontaktiere mich!',
|
||||||
projectsEyebrow: 'Aktuelle Jobs & Projekte',
|
projectsEyebrow: 'Aktuelle Jobs & Projekte',
|
||||||
openProjectPrefix: 'Projekt öffnen',
|
openProjectPrefix: 'Projekt öffnen',
|
||||||
projectLanguagesAriaLabel: 'Projekt-Technologien',
|
projectIconAltSuffix: 'Logo',
|
||||||
fallbackBioLabel: 'Tech Stack',
|
mailAriaLabel: 'E-Mail senden',
|
||||||
|
phoneAriaLabel: 'Telefonnummer anrufen',
|
||||||
|
signalAriaLabel: 'Nachricht über Signal',
|
||||||
|
giteaAriaLabel: 'Gitea-Profil',
|
||||||
|
linkedinAriaLabel: 'LinkedIn-Profil',
|
||||||
|
skillsLabel: 'Kenntnisse',
|
||||||
|
skillsLinkAriaLabel: 'Alle Kenntnisse auf LinkedIn ansehen',
|
||||||
},
|
},
|
||||||
imprint: {
|
imprint: {
|
||||||
legalEyebrow: 'Rechtliches',
|
legalEyebrow: 'Rechtliches',
|
||||||
title: 'Impressum & Datenschutz',
|
title: 'Impressum & Datenschutz',
|
||||||
intro:
|
intro:
|
||||||
'Rechtliche Informationen nach österreichischem Recht sowie Datenschutzinformationen gemäß DSGVO für diese Website von {{firstname}} {{lastname}}, handelnd unter Lechner Systems.',
|
'Rechtliche Informationen nach österreichischem Recht sowie Datenschutzinformationen gemäß DSGVO für alle Websites von {{firstname}} {{lastname}}.',
|
||||||
backToPortfolio: 'Zurück zum Portfolio',
|
backToPortfolio: 'Zurück zum Portfolio',
|
||||||
provider: 'Anbieter',
|
provider: 'Anbieter',
|
||||||
directContact: 'Direkter Kontakt',
|
directContact: 'Direkter Kontakt',
|
||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
contact: 'E-Mail',
|
contact: 'E-Mail',
|
||||||
abuse: 'Abuse 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',
|
privacyEyebrow: 'Datenschutz',
|
||||||
privacyTitle: 'Datenschutz & Datenverarbeitung',
|
privacyTitle: 'Keine Datensammlung',
|
||||||
privacyLeftParagraphs: [
|
privacyLeft:
|
||||||
'Verantwortlicher für die Datenverarbeitung im Sinne der Datenschutz-Grundverordnung (DSGVO) ist {{firstname}} {{lastname}}, erreichbar unter {{contactMail}} sowie telefonisch unter {{contactPhone}}.',
|
'Diese Website sammelt und verarbeitet keine personenbezogenen Daten. Ausgenommen sind nur Informationen, die beim Aufruf einer Website technisch bedingt übermittelt werden, etwa IP-Adresse, Datum und Uhrzeit, aufgerufene Seite, Referrer sowie Browser- und Betriebssysteminformationen (User-Agent).',
|
||||||
'Diese Website verwendet nach aktuellem Stand kein eigenes Tracking, keine Analyse-Cookies, keine Marketing-Cookies und keine langfristigen persönlichen Nutzungsprofile.',
|
privacyRight:
|
||||||
'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.',
|
'Diese Daten dienen ausschließlich dem sicheren und stabilen Betrieb der Website (Art. 6 Abs. 1 lit. f DSGVO). Es gibt kein Tracking und keine Cookies. Die gewählte Sprache wird nur lokal in deinem Browser gespeichert.',
|
||||||
],
|
|
||||||
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',
|
rightsEyebrow: 'Rechte',
|
||||||
rightsTitle: 'Betroffenenrechte & Beschwerden',
|
rightsTitle: 'Deine Rechte nach der DSGVO',
|
||||||
rightsLeft:
|
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.',
|
'Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch. Anfragen richtest du an die oben genannte E-Mail-Adresse.',
|
||||||
rightsRight:
|
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.',
|
'Beschwerden richtest du an die Österreichische Datenschutzbehörde, Barichgasse 40-42, 1030 Wien, dsb@dsb.gv.at.',
|
||||||
copyrightEyebrow: 'Urheberrecht',
|
copyrightEyebrow: 'Urheberrecht',
|
||||||
copyrightTitle: 'Urheberrecht, externe Links & anwendbares Recht',
|
copyrightTitle: 'Urheberrecht & Haftung',
|
||||||
copyrightLeftParagraphs: [
|
copyrightLeftParagraphs: [
|
||||||
'Alle Inhalte dieser Website, einschließlich Texte, Bilder, Grafiken, Fotos, Layouts, Quellcode und sonstige Werke, sind urheberrechtlich geschützt, sofern nicht anders angegeben.',
|
'Alle Inhalte aller Websites von {{firstname}} {{lastname}}, insbesondere Texte, Bilder, Fotos, Grafiken und Design, sind urheberrechtlich geschützt. Alle Rechte vorbehalten.',
|
||||||
'Jede Nutzung außerhalb der gesetzlichen Schranken des Urheberrechts benötigt eine vorherige schriftliche Zustimmung des jeweiligen Rechteinhabers.',
|
'Jede Vervielfältigung, Bearbeitung, Verbreitung oder sonstige Nutzung ist ohne meine vorherige schriftliche Zustimmung untersagt. Für den Quellcode gilt ausschließlich die im Repository angegebene Lizenz.',
|
||||||
],
|
],
|
||||||
copyrightRightParagraphs: [
|
copyrightRightParagraphs: [
|
||||||
'Diese Website kann Links zu externen Websites enthalten. Für deren Inhalte, Datenschutzpraxis und technische Sicherheit sind ausschließlich die jeweiligen Betreiber verantwortlich.',
|
'Für Inhalte verlinkter externer Websites übernehme ich keine Haftung. Dafür sind ausschließlich deren Betreiber verantwortlich.',
|
||||||
'Es gilt ausschließlich österreichisches Recht, sofern keine zwingenden Verbraucherschutzbestimmungen etwas anderes vorgeben.',
|
'Es gilt österreichisches Recht, soweit zwingende Verbraucherschutzvorschriften nichts anderes vorsehen.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
bioTextsList: [
|
bioTextsList: [
|
||||||
{ label: 'Sprachen', value: 'C#' },
|
'C#',
|
||||||
{ label: 'Sprachen', value: 'TypeScript' },
|
'TypeScript',
|
||||||
{ label: 'Sprachen', value: 'Bash' },
|
'JavaScript',
|
||||||
{ label: 'Frontend', value: 'Angular' },
|
'Java',
|
||||||
{ label: 'Frontend', value: 'WPF' },
|
'SQL',
|
||||||
{ label: 'Frontend', value: 'Avalonia' },
|
'Bash',
|
||||||
{ label: 'Backend', value: '.NET' },
|
'Windows PowerShell',
|
||||||
{ label: 'Backend', value: 'ASP.NET Core' },
|
'.NET Core',
|
||||||
{ label: 'Backend', value: 'Entity Framework Core' },
|
'.NET-Framework',
|
||||||
{ label: 'Backend', value: 'Node.js' },
|
'ASP.NET',
|
||||||
{ label: 'Backend', value: 'REST' },
|
'Entity Framework (EF) Core',
|
||||||
{ label: 'Backend', value: 'Swagger' },
|
'Windows Presentation Foundation (WPF)',
|
||||||
{ label: 'Datenbank', value: 'Microsoft SQL' },
|
'Model View ViewModel (MVVM)',
|
||||||
{ label: 'Datenbank', value: 'MariaDB' },
|
'Model View Controller (MVC)',
|
||||||
{ label: 'DevOps', value: 'Git' },
|
'Webentwicklung',
|
||||||
{ label: 'DevOps', value: 'GitHub & GitLab' },
|
'AngularJS',
|
||||||
{ label: 'DevOps', value: 'Azure DevOps' },
|
'Node.js',
|
||||||
{ label: 'DevOps', value: 'Docker' },
|
'Microsoft SQL Server',
|
||||||
{ label: 'DevOps', value: 'Ubuntu' },
|
'MariaDB',
|
||||||
{ label: 'DevOps', value: 'Arch' },
|
'Git',
|
||||||
{ label: 'Deklarative Sprachen', value: 'XAML' },
|
'GitHub',
|
||||||
{ label: 'Deklarative Sprachen', value: 'YAML' },
|
'GitLab',
|
||||||
{ label: 'Deklarative Sprachen', value: 'Markdown' },
|
'Azure DevOps',
|
||||||
|
'Continuous Integration und Continuous Delivery (CI/CD)',
|
||||||
|
'Systemadministration',
|
||||||
|
'Linux-Server',
|
||||||
|
'Windows-Server',
|
||||||
|
'Proxmox',
|
||||||
|
'E-Mail-Sicherheit & Authentifizierung: SPF, DKIM, DMARC, DNSSEC/DANE & MTA-STS',
|
||||||
|
'Künstliche Intelligenz (KI)',
|
||||||
|
'KI-Agenten',
|
||||||
|
'Anthropic Claude',
|
||||||
|
'OpenAI Codex',
|
||||||
|
'Agile',
|
||||||
|
'Scrum',
|
||||||
|
'Projektmanagement',
|
||||||
|
'Projektkommunikation',
|
||||||
],
|
],
|
||||||
portraitHighlights: [
|
portraitHighlights: [
|
||||||
{
|
{
|
||||||
@@ -166,24 +162,13 @@ export const deLanguage: LanguagePack = {
|
|||||||
imageHeight: 480,
|
imageHeight: 480,
|
||||||
text: 'Reisen',
|
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: [
|
projects: [
|
||||||
{
|
{
|
||||||
title: 'SobIT GmbH',
|
title: 'SobIT GmbH',
|
||||||
link: 'https://sobit.at/',
|
link: 'https://sobit.at/',
|
||||||
description:
|
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.',
|
'Ich arbeite hauptsächlich für die SobIT GmbH aus Wien, ein Tochterunternehmen der Wiener Sozialdienste. Wir entwickeln Software für den Gesundheitsbereich: moderne Desktop-, Mobile- und Webanwendungen zur Unterstützung der mobilen Pflege.',
|
||||||
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
|
|
||||||
icon: 'assets/optimized/logos/sobit.webp',
|
icon: 'assets/optimized/logos/sobit.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
|
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
|
||||||
@@ -195,7 +180,6 @@ export const deLanguage: LanguagePack = {
|
|||||||
link: 'https://www.synhost.de/',
|
link: 'https://www.synhost.de/',
|
||||||
description:
|
description:
|
||||||
'Ein Hosting-Dienstleister von GERLACH SYSTEMS, für die ich Schnittstellen zwischen einzelnen Systemen entwickle und technischen Support mache.',
|
'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',
|
icon: 'assets/optimized/logos/synhost.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
|
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
|
||||||
@@ -209,7 +193,6 @@ export const deLanguage: LanguagePack = {
|
|||||||
link: 'https://www.synradio.de/',
|
link: 'https://www.synradio.de/',
|
||||||
description:
|
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.',
|
'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',
|
icon: 'assets/optimized/logos/synradio.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
|
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
|
||||||
@@ -218,10 +201,9 @@ export const deLanguage: LanguagePack = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Discord Audio Stream Library',
|
title: 'Discord Audio Stream Library',
|
||||||
link: 'https://github.com/FrauJulian/Discord-Audio-Stream',
|
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream',
|
||||||
description:
|
description:
|
||||||
'Eine TypeScript-Library für Discord, die Audiowiedergabe einfacher macht, mit Fokus auf stabile 24/7-Streams ohne Unterbrechungen.',
|
'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',
|
icon: 'assets/optimized/logos/discord.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
|
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
|
||||||
@@ -233,10 +215,9 @@ export const deLanguage: LanguagePack = {
|
|||||||
link: '#',
|
link: '#',
|
||||||
description:
|
description:
|
||||||
'Diese Portfolio-Website, die du gerade anschaust, ist eines meiner aktiv gepflegten Projekte. 😁',
|
'Diese Portfolio-Website, die du gerade anschaust, ist eines meiner aktiv gepflegten Projekte. 😁',
|
||||||
skills: ['TypeScript', 'Angular', 'YML'],
|
|
||||||
icon: 'assets/optimized/portrait/me.webp',
|
icon: 'assets/optimized/portrait/me.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
|
'assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me-320.webp 320w',
|
||||||
iconSizes: '(max-width: 700px) 64px, 108px',
|
iconSizes: '(max-width: 700px) 64px, 108px',
|
||||||
iconWidth: 320,
|
iconWidth: 320,
|
||||||
iconHeight: 385,
|
iconHeight: 385,
|
||||||
@@ -247,7 +228,6 @@ export const deLanguage: LanguagePack = {
|
|||||||
link: 'https://tauchertreff-mostviertel.at/',
|
link: 'https://tauchertreff-mostviertel.at/',
|
||||||
description:
|
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.',
|
'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',
|
icon: 'assets/optimized/logos/tauchertreff.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
|
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
|
||||||
|
|||||||
+61
-81
@@ -1,18 +1,6 @@
|
|||||||
import type { LanguagePack } from './language.types';
|
import type { LanguagePack } from './language.types';
|
||||||
|
|
||||||
export const enLanguage: LanguagePack = {
|
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: {
|
home: {
|
||||||
portfolioEyebrow: 'Portfolio',
|
portfolioEyebrow: 'Portfolio',
|
||||||
heroIntro:
|
heroIntro:
|
||||||
@@ -41,79 +29,87 @@ export const enLanguage: LanguagePack = {
|
|||||||
getInTouch: 'Get in touch!',
|
getInTouch: 'Get in touch!',
|
||||||
projectsEyebrow: 'Current Jobs & Projects',
|
projectsEyebrow: 'Current Jobs & Projects',
|
||||||
openProjectPrefix: 'Open project',
|
openProjectPrefix: 'Open project',
|
||||||
projectLanguagesAriaLabel: 'Project languages',
|
projectIconAltSuffix: 'logo',
|
||||||
fallbackBioLabel: 'my stack',
|
mailAriaLabel: 'Send an email',
|
||||||
|
phoneAriaLabel: 'Call phone number',
|
||||||
|
signalAriaLabel: 'Message on Signal',
|
||||||
|
giteaAriaLabel: 'Gitea profile',
|
||||||
|
linkedinAriaLabel: 'LinkedIn profile',
|
||||||
|
skillsLabel: 'Skills',
|
||||||
|
skillsLinkAriaLabel: 'View all skills on LinkedIn',
|
||||||
},
|
},
|
||||||
imprint: {
|
imprint: {
|
||||||
legalEyebrow: 'Legal',
|
legalEyebrow: 'Legal',
|
||||||
title: 'Imprint & Privacy',
|
title: 'Imprint & Privacy',
|
||||||
intro:
|
intro:
|
||||||
'Legal information under Austrian law and privacy information under the GDPR for this website operated by {{firstname}} {{lastname}}, trading as Lechner Systems.',
|
'Legal information under Austrian law and privacy information under the GDPR for all websites operated by {{firstname}} {{lastname}}.',
|
||||||
backToPortfolio: 'Back to portfolio',
|
backToPortfolio: 'Back to portfolio',
|
||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
directContact: 'Direct Contact',
|
directContact: 'Direct Contact',
|
||||||
phone: 'Phone',
|
phone: 'Phone',
|
||||||
contact: 'Email',
|
contact: 'Email',
|
||||||
abuse: 'Abuse 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',
|
privacyEyebrow: 'Privacy',
|
||||||
privacyTitle: 'Privacy And Data Processing',
|
privacyTitle: 'No Data Collection',
|
||||||
privacyLeftParagraphs: [
|
privacyLeft:
|
||||||
'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}}.',
|
'This website does not collect or process personal data. The only exception is information that is technically transmitted when a website is accessed, such as IP address, date and time, requested page, referrer, and browser and operating system information (user agent).',
|
||||||
'As currently implemented, this website does not use its own tracking, analytics cookies, marketing cookies, or long-term personal usage profiling.',
|
privacyRight:
|
||||||
'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.',
|
'This data is used solely to operate the website securely and reliably (Art. 6(1)(f) GDPR). There is no tracking and there are no cookies. Your chosen language is stored only locally in your browser.',
|
||||||
],
|
|
||||||
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',
|
rightsEyebrow: 'Rights',
|
||||||
rightsTitle: 'Data Subject Rights And Complaints',
|
rightsTitle: 'Your Rights Under The GDPR',
|
||||||
rightsLeft:
|
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.',
|
'You have the right of access, rectification, erasure, restriction of processing, data portability, and objection. Send requests to the email address listed above.',
|
||||||
rightsRight:
|
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.',
|
'Complaints can be filed with the Austrian Data Protection Authority, Barichgasse 40-42, 1030 Vienna, dsb@dsb.gv.at.',
|
||||||
copyrightEyebrow: 'Copyright',
|
copyrightEyebrow: 'Copyright',
|
||||||
copyrightTitle: 'Copyright, External Links, And Applicable Law',
|
copyrightTitle: 'Copyright & Liability',
|
||||||
copyrightLeftParagraphs: [
|
copyrightLeftParagraphs: [
|
||||||
'All content on this website, including texts, images, graphics, photographs, layouts, source code, and other works, is protected by copyright unless stated otherwise.',
|
'All content on all websites operated by {{firstname}} {{lastname}}, in particular texts, images, photographs, graphics, and design, is protected by copyright. All rights reserved.',
|
||||||
'Any use beyond statutory exceptions requires prior written consent of the respective rights holder.',
|
'Any reproduction, modification, distribution, or other use without my prior written consent is prohibited. The source code is governed solely by the license stated in its repository.',
|
||||||
],
|
],
|
||||||
copyrightRightParagraphs: [
|
copyrightRightParagraphs: [
|
||||||
'This website may contain links to third-party websites. Their operators are solely responsible for their contents, privacy practices, and technical security.',
|
'I accept no liability for the content of linked external websites. Their operators are solely responsible for it.',
|
||||||
'Austrian law applies exclusively, unless mandatory consumer protection provisions require otherwise.',
|
'Austrian law applies, unless mandatory consumer protection provisions provide otherwise.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
bioTextsList: [
|
bioTextsList: [
|
||||||
{ label: 'Languages', value: 'C#' },
|
'C#',
|
||||||
{ label: 'Languages', value: 'TypeScript' },
|
'TypeScript',
|
||||||
{ label: 'Languages', value: 'Bash' },
|
'JavaScript',
|
||||||
{ label: 'Frontend', value: 'Angular' },
|
'Java',
|
||||||
{ label: 'Frontend', value: 'WPF' },
|
'SQL',
|
||||||
{ label: 'Frontend', value: 'Avalonia' },
|
'Bash',
|
||||||
{ label: 'Backend', value: '.NET' },
|
'Windows PowerShell',
|
||||||
{ label: 'Backend', value: 'ASP.NET Core' },
|
'.NET Core',
|
||||||
{ label: 'Backend', value: 'Entity Framework Core' },
|
'.NET Framework',
|
||||||
{ label: 'Backend', value: 'Node.js' },
|
'ASP.NET',
|
||||||
{ label: 'Backend', value: 'REST' },
|
'Entity Framework (EF) Core',
|
||||||
{ label: 'Backend', value: 'Swagger' },
|
'Windows Presentation Foundation (WPF)',
|
||||||
{ label: 'Database', value: 'Microsoft SQL' },
|
'Model View ViewModel (MVVM)',
|
||||||
{ label: 'Database', value: 'MariaDB' },
|
'Model View Controller (MVC)',
|
||||||
{ label: 'DevOps', value: 'Git' },
|
'Web Development',
|
||||||
{ label: 'DevOps', value: 'GitHub & GitLab' },
|
'AngularJS',
|
||||||
{ label: 'DevOps', value: 'Azure DevOps' },
|
'Node.js',
|
||||||
{ label: 'DevOps', value: 'Docker' },
|
'Microsoft SQL Server',
|
||||||
{ label: 'DevOps', value: 'Ubuntu' },
|
'MariaDB',
|
||||||
{ label: 'DevOps', value: 'Arch' },
|
'Git',
|
||||||
{ label: 'Declarative Languages', value: 'XAML' },
|
'GitHub',
|
||||||
{ label: 'Declarative Languages', value: 'YAML' },
|
'GitLab',
|
||||||
{ label: 'Declarative Languages', value: 'Markdown' },
|
'Azure DevOps',
|
||||||
|
'Continuous Integration and Continuous Delivery (CI/CD)',
|
||||||
|
'System Administration',
|
||||||
|
'Linux Server',
|
||||||
|
'Windows Server',
|
||||||
|
'Proxmox',
|
||||||
|
'Email Security & Authentication: SPF, DKIM, DMARC, DNSSEC/DANE & MTA-STS',
|
||||||
|
'Artificial Intelligence (AI)',
|
||||||
|
'AI Agents',
|
||||||
|
'Anthropic Claude',
|
||||||
|
'OpenAI Codex',
|
||||||
|
'Agile',
|
||||||
|
'Scrum',
|
||||||
|
'Project Management',
|
||||||
|
'Project Communication',
|
||||||
],
|
],
|
||||||
portraitHighlights: [
|
portraitHighlights: [
|
||||||
{
|
{
|
||||||
@@ -166,16 +162,6 @@ export const enLanguage: LanguagePack = {
|
|||||||
imageHeight: 480,
|
imageHeight: 480,
|
||||||
text: 'Traveling',
|
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: [
|
projects: [
|
||||||
{
|
{
|
||||||
@@ -183,7 +169,6 @@ export const enLanguage: LanguagePack = {
|
|||||||
link: 'https://sobit.at/',
|
link: 'https://sobit.at/',
|
||||||
description:
|
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.',
|
'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',
|
icon: 'assets/optimized/logos/sobit.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
|
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
|
||||||
@@ -195,7 +180,6 @@ export const enLanguage: LanguagePack = {
|
|||||||
link: 'https://www.synhost.de/',
|
link: 'https://www.synhost.de/',
|
||||||
description:
|
description:
|
||||||
'A hosting service provider of GERLACH SYSTEMS, for whom I develop interfaces between individual systems and provide technical support.',
|
'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',
|
icon: 'assets/optimized/logos/synhost.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
|
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
|
||||||
@@ -209,7 +193,6 @@ export const enLanguage: LanguagePack = {
|
|||||||
link: 'https://www.synradio.de/',
|
link: 'https://www.synradio.de/',
|
||||||
description:
|
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.',
|
'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',
|
icon: 'assets/optimized/logos/synradio.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
|
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
|
||||||
@@ -218,10 +201,9 @@ export const enLanguage: LanguagePack = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Discord Audio Stream Library',
|
title: 'Discord Audio Stream Library',
|
||||||
link: 'https://github.com/FrauJulian/Discord-Audio-Stream',
|
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream',
|
||||||
description:
|
description:
|
||||||
'A TypeScript library for Discord that simplifies audio playback, with a focus on stable 24/7 streaming without interruptions.',
|
'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',
|
icon: 'assets/optimized/logos/discord.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
|
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
|
||||||
@@ -233,10 +215,9 @@ export const enLanguage: LanguagePack = {
|
|||||||
link: '',
|
link: '',
|
||||||
description:
|
description:
|
||||||
'This portfolio website you’re currently viewing is one of my actively maintained projects. 😁',
|
'This portfolio website you’re currently viewing is one of my actively maintained projects. 😁',
|
||||||
skills: ['TypeScript', 'Angular', 'YML'],
|
|
||||||
icon: 'assets/optimized/portrait/me.webp',
|
icon: 'assets/optimized/portrait/me.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
|
'assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me-320.webp 320w',
|
||||||
iconSizes: '(max-width: 700px) 64px, 108px',
|
iconSizes: '(max-width: 700px) 64px, 108px',
|
||||||
iconWidth: 320,
|
iconWidth: 320,
|
||||||
iconHeight: 385,
|
iconHeight: 385,
|
||||||
@@ -247,7 +228,6 @@ export const enLanguage: LanguagePack = {
|
|||||||
link: 'https://tauchertreff-mostviertel.at/',
|
link: 'https://tauchertreff-mostviertel.at/',
|
||||||
description:
|
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.',
|
'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',
|
icon: 'assets/optimized/logos/tauchertreff.webp',
|
||||||
iconSrcset:
|
iconSrcset:
|
||||||
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
|
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export interface LanguageProject {
|
|||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
description: string;
|
description: string;
|
||||||
skills: string[];
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconSrcset?: string;
|
iconSrcset?: string;
|
||||||
iconSizes?: string;
|
iconSizes?: string;
|
||||||
@@ -13,11 +12,6 @@ export interface LanguageProject {
|
|||||||
circleIcon?: boolean;
|
circleIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LanguageBioTextEntry {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LanguagePortraitHighlight {
|
export interface LanguagePortraitHighlight {
|
||||||
image: string;
|
image: string;
|
||||||
imageSrcset: string;
|
imageSrcset: string;
|
||||||
@@ -27,7 +21,7 @@ export interface LanguagePortraitHighlight {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LanguagePack {
|
export interface LanguageShellPack {
|
||||||
app: {
|
app: {
|
||||||
selectorTitle: string;
|
selectorTitle: string;
|
||||||
changeLanguage: string;
|
changeLanguage: string;
|
||||||
@@ -37,9 +31,17 @@ export interface LanguagePack {
|
|||||||
languageGerman: string;
|
languageGerman: string;
|
||||||
};
|
};
|
||||||
footer: {
|
footer: {
|
||||||
noscriptMessage: string;
|
|
||||||
imprintLink: string;
|
imprintLink: string;
|
||||||
|
homeLink: string;
|
||||||
};
|
};
|
||||||
|
meta: {
|
||||||
|
description: string;
|
||||||
|
homeTitle: string;
|
||||||
|
imprintTitle: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguagePack {
|
||||||
home: {
|
home: {
|
||||||
portfolioEyebrow: string;
|
portfolioEyebrow: string;
|
||||||
heroIntro: string;
|
heroIntro: string;
|
||||||
@@ -59,8 +61,14 @@ export interface LanguagePack {
|
|||||||
getInTouch: string;
|
getInTouch: string;
|
||||||
projectsEyebrow: string;
|
projectsEyebrow: string;
|
||||||
openProjectPrefix: string;
|
openProjectPrefix: string;
|
||||||
projectLanguagesAriaLabel: string;
|
projectIconAltSuffix: string;
|
||||||
fallbackBioLabel: string;
|
mailAriaLabel: string;
|
||||||
|
phoneAriaLabel: string;
|
||||||
|
signalAriaLabel: string;
|
||||||
|
giteaAriaLabel: string;
|
||||||
|
linkedinAriaLabel: string;
|
||||||
|
skillsLabel: string;
|
||||||
|
skillsLinkAriaLabel: string;
|
||||||
};
|
};
|
||||||
imprint: {
|
imprint: {
|
||||||
legalEyebrow: string;
|
legalEyebrow: string;
|
||||||
@@ -72,14 +80,10 @@ export interface LanguagePack {
|
|||||||
phone: string;
|
phone: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
abuse: string;
|
abuse: string;
|
||||||
scopeEyebrow: string;
|
|
||||||
scopeTitle: string;
|
|
||||||
scopeLeft: string;
|
|
||||||
scopeRight: string;
|
|
||||||
privacyEyebrow: string;
|
privacyEyebrow: string;
|
||||||
privacyTitle: string;
|
privacyTitle: string;
|
||||||
privacyLeftParagraphs: string[];
|
privacyLeft: string;
|
||||||
privacyRightParagraphs: string[];
|
privacyRight: string;
|
||||||
rightsEyebrow: string;
|
rightsEyebrow: string;
|
||||||
rightsTitle: string;
|
rightsTitle: string;
|
||||||
rightsLeft: string;
|
rightsLeft: string;
|
||||||
@@ -89,9 +93,7 @@ export interface LanguagePack {
|
|||||||
copyrightLeftParagraphs: string[];
|
copyrightLeftParagraphs: string[];
|
||||||
copyrightRightParagraphs: string[];
|
copyrightRightParagraphs: string[];
|
||||||
};
|
};
|
||||||
bioTextsList: LanguageBioTextEntry[];
|
bioTextsList: string[];
|
||||||
portraitHighlights: LanguagePortraitHighlight[];
|
portraitHighlights: LanguagePortraitHighlight[];
|
||||||
projects: LanguageProject[];
|
projects: LanguageProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LanguageShellPack = Pick<LanguagePack, 'app' | 'footer'>;
|
|
||||||
+14
-2
@@ -10,8 +10,14 @@ export const enShellLanguage: LanguageShellPack = {
|
|||||||
languageGerman: 'German',
|
languageGerman: 'German',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
noscriptMessage: 'Please enable JavaScript for the full experience.',
|
|
||||||
imprintLink: 'Imprint',
|
imprintLink: 'Imprint',
|
||||||
|
homeLink: 'Portfolio',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
description:
|
||||||
|
'Full-stack developer building reliable, high-performance systems and clean, maintainable code. Focused on practical solutions that actually hold up in production.',
|
||||||
|
homeTitle: 'Julian Lechner – Full-Stack Developer',
|
||||||
|
imprintTitle: 'Imprint & Privacy – Julian Lechner',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +31,13 @@ export const deShellLanguage: LanguageShellPack = {
|
|||||||
languageGerman: 'Deutsch',
|
languageGerman: 'Deutsch',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
noscriptMessage: 'Bitte aktiviere JavaScript um alles zu sehen.',
|
|
||||||
imprintLink: 'Impressum',
|
imprintLink: 'Impressum',
|
||||||
|
homeLink: 'Portfolio',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
description:
|
||||||
|
'Full-Stack-Entwickler für zuverlässige, performante Systeme und sauberen, wartbaren Code. Fokus auf praktische Lösungen, die sich im Produktivbetrieb bewähren.',
|
||||||
|
homeTitle: 'Julian Lechner – Full-Stack-Entwickler',
|
||||||
|
imprintTitle: 'Impressum & Datenschutz – Julian Lechner',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
const bootstrap: (context: BootstrapContext) => Promise<ApplicationRef> = (
|
|
||||||
context: BootstrapContext,
|
|
||||||
): Promise<ApplicationRef> => bootstrapApplication(AppComponent, config, context);
|
|
||||||
|
|
||||||
export default bootstrap;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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({ allowedHosts });
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
express.static(browserDistFolder, {
|
|
||||||
maxAge: '1y',
|
|
||||||
index: 'index.html',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get('/{*splat}', (req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const { protocol, originalUrl, baseUrl, headers } = req;
|
|
||||||
|
|
||||||
commonEngine
|
|
||||||
.render({
|
|
||||||
bootstrap,
|
|
||||||
documentFilePath: indexHtml,
|
|
||||||
url: `${protocol}://${headers.host}${originalUrl}`,
|
|
||||||
publicPath: browserDistFolder,
|
|
||||||
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
|
||||||
})
|
|
||||||
.then((html: string): Response => res.send(html))
|
|
||||||
.catch((err: Error): void => next(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMainModule(import.meta.url)) {
|
|
||||||
const port: number = 3000;
|
|
||||||
|
|
||||||
app.listen(port, (): void => {
|
|
||||||
console.log(`Listening on port ${port}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
+19
-1
@@ -2,8 +2,26 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fraujulian.xyz</loc>
|
<loc>https://fraujulian.xyz</loc>
|
||||||
<lastmod>2026-04-17</lastmod>
|
<lastmod>2026-10-15</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fraujulian.xyz/?lang=de</loc>
|
||||||
|
<lastmod>2026-10-15</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fraujulian.xyz/imprint</loc>
|
||||||
|
<lastmod>2026-10-15</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fraujulian.xyz/imprint?lang=de</loc>
|
||||||
|
<lastmod>2026-10-15</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
+124
-90
@@ -1,3 +1,19 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('assets/optimized/fonts/outfit-latin.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by desktop and touch devices so both show the same tones. The absolute URL keeps the
|
||||||
|
// file name stable so index.html can preload it.
|
||||||
|
$page-background:
|
||||||
|
radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.28), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(9, 9, 12, 0.4), rgba(8, 8, 11, 0.82)),
|
||||||
|
url('/assets/optimized/background.webp') center / cover fixed no-repeat;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--page-max-width: 1000px;
|
--page-max-width: 1000px;
|
||||||
--content-max-width: 920px;
|
--content-max-width: 920px;
|
||||||
@@ -7,7 +23,7 @@
|
|||||||
--panel-background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
|
--panel-background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
|
||||||
--border-soft: rgba(255, 255, 255, 0.1);
|
--border-soft: rgba(255, 255, 255, 0.1);
|
||||||
--text-primary: #f7f1ea;
|
--text-primary: #f7f1ea;
|
||||||
--text-muted: rgba(247, 241, 234, 0.74);
|
--text-muted: rgba(247, 241, 234, 0.86);
|
||||||
--accent: #d9a28c;
|
--accent: #d9a28c;
|
||||||
--accent-strong: #efbf9d;
|
--accent-strong: #efbf9d;
|
||||||
--shadow: 0 28px 70px rgba(7, 7, 11, 0.34);
|
--shadow: 0 28px 70px rgba(7, 7, 11, 0.34);
|
||||||
@@ -64,11 +80,7 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
background-color: #08080b;
|
background-color: #08080b;
|
||||||
background:
|
background: $page-background;
|
||||||
radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%),
|
|
||||||
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.28), transparent 28%),
|
|
||||||
linear-gradient(180deg, rgba(9, 9, 12, 0.4), rgba(8, 8, 11, 0.82)),
|
|
||||||
url('assets/optimized/background.webp') center / cover fixed no-repeat;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +89,10 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -221,7 +237,6 @@ hr {
|
|||||||
.story-detail,
|
.story-detail,
|
||||||
.project-copy,
|
.project-copy,
|
||||||
.project-title-wrap,
|
.project-title-wrap,
|
||||||
.project-languages,
|
|
||||||
.legal-contact-box,
|
.legal-contact-box,
|
||||||
.language-copy,
|
.language-copy,
|
||||||
.language-switcher-copy {
|
.language-switcher-copy {
|
||||||
@@ -253,7 +268,6 @@ hr {
|
|||||||
.legal-contact-box p,
|
.legal-contact-box p,
|
||||||
.legal-provider-name,
|
.legal-provider-name,
|
||||||
.project-link,
|
.project-link,
|
||||||
.project-language-chip,
|
|
||||||
.portrait-caption,
|
.portrait-caption,
|
||||||
.portrait-click-hint,
|
.portrait-click-hint,
|
||||||
.inline-link {
|
.inline-link {
|
||||||
@@ -270,7 +284,7 @@ hr {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +297,8 @@ hr {
|
|||||||
|
|
||||||
.hero-contact-block {
|
.hero-contact-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: start;
|
||||||
|
margin-top: var(--space-3);
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
width: min(100%, 22rem);
|
width: min(100%, 22rem);
|
||||||
}
|
}
|
||||||
@@ -295,7 +310,7 @@ hr {
|
|||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-contact-area {
|
.hero-contact-area {
|
||||||
@@ -437,6 +452,35 @@ hr {
|
|||||||
border-top: 1px solid var(--border-soft);
|
border-top: 1px solid var(--border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-visual .hero-meta {
|
||||||
|
max-width: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(12, 12, 16, 0.42);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual .hero-meta {
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual .hero-meta:hover,
|
||||||
|
.hero-visual .hero-meta:focus-visible {
|
||||||
|
border-color: rgba(239, 191, 157, 0.34);
|
||||||
|
background: rgba(12, 12, 16, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual .hero-meta div {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
@@ -497,32 +541,30 @@ hr {
|
|||||||
box-shadow: 0 20px 48px rgba(9, 9, 12, 0.34);
|
box-shadow: 0 20px 48px rgba(9, 9, 12, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portrait-caption {
|
.portrait-caption,
|
||||||
|
.portrait-click-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
max-width: calc(100% - 2.2rem);
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border: 1px solid rgba(239, 191, 157, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(12, 12, 16, 0.78);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portrait-caption {
|
||||||
right: 1.1rem;
|
right: 1.1rem;
|
||||||
bottom: 1.1rem;
|
bottom: 1.1rem;
|
||||||
max-width: calc(100% - 2.2rem);
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #000000;
|
|
||||||
font-size: 1.08rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portrait-click-hint {
|
.portrait-click-hint {
|
||||||
position: absolute;
|
|
||||||
left: 1.1rem;
|
left: 1.1rem;
|
||||||
top: 1.1rem;
|
top: 1.1rem;
|
||||||
max-width: calc(100% - 2.2rem);
|
color: var(--accent-strong);
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #000000;
|
|
||||||
font-size: 1.02rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-note {
|
.hero-note {
|
||||||
@@ -810,9 +852,9 @@ hr {
|
|||||||
|
|
||||||
.project-header {
|
.project-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-title-wrap {
|
.project-title-wrap {
|
||||||
@@ -836,31 +878,25 @@ hr {
|
|||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-link:hover {
|
.project-entry:hover .project-link,
|
||||||
|
.project-entry:focus-visible .project-link {
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-languages {
|
.project-title-tail {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.45rem;
|
|
||||||
max-width: 22rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-language-chip {
|
.project-arrow {
|
||||||
display: inline-flex;
|
margin-left: 0.45em;
|
||||||
align-items: center;
|
font-size: 0.6em;
|
||||||
min-height: 1.9rem;
|
vertical-align: 0.2em;
|
||||||
padding: 0.15rem 0.65rem;
|
transition: transform var(--transition-fast);
|
||||||
border-radius: 999px;
|
}
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
.project-entry:hover .project-arrow,
|
||||||
color: var(--text-muted);
|
.project-entry:focus-visible .project-arrow {
|
||||||
font-size: 0.82rem;
|
transform: translateX(4px);
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-description {
|
.project-description {
|
||||||
@@ -891,6 +927,11 @@ hr {
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.72fr);
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.72fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First card on the page: starts at the same height as the home hero.
|
||||||
|
.imprint-page .legal-hero {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.legal-hero .back-link {
|
.legal-hero .back-link {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -1007,16 +1048,6 @@ hr {
|
|||||||
padding: clamp(1.1rem, 3.8vw, 1.5rem);
|
padding: clamp(1.1rem, 3.8vw, 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-languages {
|
|
||||||
justify-content: flex-start;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal-hero {
|
.legal-hero {
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1076,15 @@ hr {
|
|||||||
padding: var(--space-4) 0 var(--space-6);
|
padding: var(--space-4) 0 var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-contact-block {
|
||||||
|
justify-items: center;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-contact-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-section {
|
.hero-section {
|
||||||
padding: clamp(1rem, 4vw, 1.2rem);
|
padding: clamp(1rem, 4vw, 1.2rem);
|
||||||
}
|
}
|
||||||
@@ -1113,12 +1153,9 @@ hr {
|
|||||||
|
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
order: 3;
|
order: 3;
|
||||||
margin-top: var(--space-4);
|
// Pulls the buttons up against the section gap so they sit as close to the skills box
|
||||||
}
|
// as the skills box sits to the portrait.
|
||||||
|
margin-top: calc(var(--space-4) - var(--space-6));
|
||||||
.hero-meta {
|
|
||||||
order: 4;
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-meta,
|
.hero-meta,
|
||||||
@@ -1177,12 +1214,6 @@ hr {
|
|||||||
font-size: 0.93rem;
|
font-size: 0.93rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-language-chip {
|
|
||||||
min-height: 1.6rem;
|
|
||||||
padding: 0.08rem 0.52rem;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions,
|
.hero-actions,
|
||||||
.hero-contact-area,
|
.hero-contact-area,
|
||||||
.hero-action-links {
|
.hero-action-links {
|
||||||
@@ -1214,12 +1245,6 @@ hr {
|
|||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel,
|
|
||||||
.language-overlay,
|
|
||||||
.language-switcher {
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.projects-section,
|
.projects-section,
|
||||||
.story-section,
|
.story-section,
|
||||||
.legal-section {
|
.legal-section {
|
||||||
@@ -1240,11 +1265,7 @@ hr {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background: $page-background;
|
||||||
radial-gradient(circle at top left, rgba(217, 162, 140, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.24), transparent 32%),
|
|
||||||
linear-gradient(180deg, rgba(9, 9, 12, 0.72), rgba(8, 8, 11, 0.96)),
|
|
||||||
url('assets/optimized/background.webp') center / cover fixed no-repeat;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1312,11 +1333,21 @@ hr {
|
|||||||
will-change: auto;
|
will-change: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
html:has(.language-overlay[open]) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.language-overlay {
|
.language-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: grid;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background:
|
background:
|
||||||
@@ -1325,6 +1356,14 @@ hr {
|
|||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-overlay[open] {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-overlay::backdrop {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.language-card {
|
.language-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1525,11 +1564,6 @@ hr {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content[aria-hidden='true'] {
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.language-card {
|
.language-card {
|
||||||
width: min(100%, 440px);
|
width: min(100%, 440px);
|
||||||
|
|||||||
+5
-3
@@ -1,9 +1,11 @@
|
|||||||
|
/* 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",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"files": ["src/main.ts", "src/main.server.ts", "src/server.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"include": ["src/**/*.d.ts"]
|
"exclude": ["src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
+18
-11
@@ -1,26 +1,33 @@
|
|||||||
|
/* 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,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
|
||||||
"outDir": "./dist/out-tsc",
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"isolatedModules": true,
|
||||||
"sourceMap": true,
|
"experimentalDecorators": true,
|
||||||
"declaration": false,
|
"importHelpers": true,
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "preserve"
|
||||||
"lib": ["ES2022", "DOM"],
|
|
||||||
"types": []
|
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
"strictTemplates": true
|
"strictTemplates": true
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"]
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/out-tsc/spec",
|
"outDir": "./dist/out-tsc/spec",
|
||||||
"types": ["jasmine", "node"]
|
"types": ["vitest/globals", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user