Compare commits

..
Author SHA1 Message Date
dependabot[bot] 69e54b1b86 chore(deps-dev): bump eslint from 9.39.4 to 10.4.0
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.4 to 10.4.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.4.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 23:18:00 +00:00
28 changed files with 13347 additions and 5140 deletions

No files matched your search

+1 -1
View File
@@ -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
.gitea .github
.husky .husky
.claude .claude
-2
View File
@@ -1,2 +0,0 @@
# Require one of the structured issue templates below.
blank_issues_enabled: false
-133
View File
@@ -1,133 +0,0 @@
name: Publish portfolio
description: Build the image and create the matching Gitea release
inputs:
image-tag:
description: Docker image tag
required: true
prerelease:
description: Whether the Gitea release is a pre-release
required: true
registry-username:
description: Registry username
required: true
registry-password:
description: Registry password or token
required: true
gitea-token:
description: Token for Gitea releases
required: true
runs:
using: composite
steps:
- name: Use Node.js v24.15.0
uses: actions/setup-node@v4
with:
node-version: '24.15.0'
- name: Set build version
shell: bash
run: npm run version:set
- name: Read version from package.json
id: version
shell: bash
env:
PRERELEASE: ${{ inputs.prerelease }}
run: |
version=$(node -p 'require("./package.json").version')
release_tag="v${version//+/-}"
if [ "$PRERELEASE" = true ]; then
release_tag="${release_tag}-dev"
fi
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: docker.lechner-systems.at
username: ${{ inputs.registry-username }}
password: ${{ inputs.registry-password }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
VCS_REF=${{ steps.version.outputs.revision }}
tags: docker.lechner-systems.at/lechnersystems/portfolio:${{ inputs.image-tag }}
- name: Create Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ inputs.gitea-token }}
RELEASE_TAG: ${{ steps.version.outputs.release_tag }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
commit_sha="$(git rev-parse HEAD)"
api_url="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases"
# ponytail: 50 recent releases cover normal reruns; paginate if old releases are rebuilt.
releases="$(curl --fail-with-body --silent --show-error \
--header "Authorization: token ${GITEA_TOKEN}" \
"${api_url}?limit=50")"
previous_tag="$(printf '%s' "$releases" | jq -r \
--arg tag "$RELEASE_TAG" \
--argjson prerelease "$PRERELEASE" \
'[.[] | select(.draft == false and .prerelease == $prerelease and .tag_name != $tag)][0].tag_name // empty')"
if [ -n "$previous_tag" ]; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${commit_sha}")"
full_changelog="${{ gitea.server_url }}/${{ gitea.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "$commit_sha")"
full_changelog=""
fi
if [ -z "$commit_messages" ]; then
commit_messages='- No commits since the previous release'
fi
{
echo '## Changes'
echo
printf '%s\n' "$commit_messages"
echo
if [ -n "$full_changelog" ]; then
echo "**Full Changelog**: ${full_changelog}"
fi
} > release-notes.md
jq --null-input \
--arg tag "$RELEASE_TAG" \
--arg target "$commit_sha" \
--argjson prerelease "$PRERELEASE" \
--rawfile body release-notes.md \
'{tag_name: $tag, target_commitish: $target, name: $tag, body: $body, prerelease: $prerelease}' \
> release.json
release_id="$(printf '%s' "$releases" | jq -r \
--arg tag "$RELEASE_TAG" \
'.[] | select(.tag_name == $tag) | .id')"
if [ -n "$release_id" ]; then
method=PATCH
release_url="${api_url}/${release_id}"
else
method=POST
release_url="$api_url"
fi
curl --fail-with-body --silent --show-error \
--request "$method" \
--header "Authorization: token ${GITEA_TOKEN}" \
--header 'Content-Type: application/json' \
--data-binary @release.json \
"$release_url"
-65
View File
@@ -1,65 +0,0 @@
name: CD Development
on:
workflow_dispatch:
schedule:
- cron: '0 23 * * *'
jobs:
changes:
runs-on: ubuntu-latest
permissions:
code: read
outputs:
changed: ${{ steps.changes.outputs.changed }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check changes since previous development release
id: changes
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/actions/workflows/CD%20Development.yml/runs
run: |
runs="$(curl --fail-with-body --silent --show-error \
--header "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}?branch=main&limit=50")"
previous_sha="$(printf '%s' "$runs" | jq -r \
--argjson current_run "$GITHUB_RUN_ID" \
'[.workflow_runs[] | select(.id != $current_run and .status == "success" and (.event == "schedule" or .event == "workflow_dispatch"))][0].head_sha // empty')"
if [ -z "$previous_sha" ]; then
echo 'changed=true' >> "$GITHUB_OUTPUT"
else
git cat-file -e "${previous_sha}^{commit}"
if git diff --quiet "$previous_sha" HEAD; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
echo 'No changes since the previous successful development release.'
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
fi
publish:
needs: changes
if: needs.changes.outputs.changed == 'true'
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish development image and pre-release
uses: ./.gitea/actions/publish
with:
image-tag: dev
prerelease: 'true'
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
gitea-token: ${{ secrets.GITEA_TOKEN }}
-25
View File
@@ -1,25 +0,0 @@
name: CD Production
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish latest image and stable release
uses: ./.gitea/actions/publish
with:
image-tag: latest
prerelease: 'false'
registry-username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
registry-password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
gitea-token: ${{ secrets.GITEA_TOKEN }}
-63
View File
@@ -1,63 +0,0 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
lint:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run lint
formatting:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run format:check
metadata:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run version:check
- run: npm run text:check
tests:
runs-on: ubuntu-latest
permissions:
code: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run test
+15
View File
@@ -0,0 +1,15 @@
# 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,7 +1,6 @@
name: 🐛 Bug Report name: 🐛 Bug Report
about: Report a bug or unexpected behavior description: Report a bug or unexpected behavior
title: '🐛 [BUG]: ' title: '🐛 [BUG]: '
ref: main
body: body:
- type: textarea - type: textarea
id: information id: information
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -1,7 +1,6 @@
name: 💡 Enhancement name: 💡 Enhancement
about: Suggest a new feature or improvement description: Suggest a new feature or improvement
title: '💡 [ENHANCEMENT]: ' title: '💡 [ENHANCEMENT]: '
ref: main
body: body:
- type: textarea - type: textarea
id: description id: description
@@ -1,7 +1,6 @@
name: ⚙️ Improvement name: ⚙️ Improvement
about: Suggest an improvement to existing functionality, code quality, or performance description: 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
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
+61
View File
@@ -0,0 +1,61 @@
name: Build Docker Image - Dev
on:
workflow_dispatch:
push:
branches:
- main
env:
REGISTRY: docker.lechner-systems.at
IMAGE_NAME: lechnersystems/portfolio
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: Set build version
run: npm run version:set
- name: Read version from package.json
id: version
run: |
version=$(node -p 'require("./package.json").version')
docker_tag=$version
revision=${GITHUB_RUN_ATTEMPT}
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$GITHUB_OUTPUT"
echo "revision=$revision" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and push dev image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
VCS_REF=${{ steps.version.outputs.revision }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
+95
View File
@@ -0,0 +1,95 @@
name: Build Docker Image - Latest
on:
workflow_dispatch:
env:
REGISTRY: docker.lechner-systems.at
IMAGE_NAME: lechnersystems/portfolio
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: Set build version
run: npm run version:set
- name: Read version from package.json
id: version
run: |
version=$(node -p 'require("./package.json").version')
docker_tag=${version//+/-}
revision=${GITHUB_RUN_ATTEMPT}
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "docker_tag=$docker_tag" >> "$GITHUB_OUTPUT"
echo "revision=$revision" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and push latest image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
VCS_REF=${{ steps.version.outputs.revision }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: v${{ steps.version.outputs.docker_tag }}
run: |
previous_tag="$(gh api repos/${{ github.repository }}/releases --paginate --jq '.[] | select(.draft == false) | .tag_name' | grep -Fxv "$RELEASE_TAG" | head -n 1 || true)"
if [ -n "$previous_tag" ]; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITHUB_SHA}")"
full_changelog="https://github.com/${{ github.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_SHA}")"
full_changelog=""
fi
if [ -z "$commit_messages" ]; then
commit_messages='- No commits since the previous release'
fi
{
echo '## Changes'
echo
printf '%s\n' "$commit_messages"
echo
if [ -n "$full_changelog" ]; then
echo "**Full Changelog**: ${full_changelog}"
fi
} > release-notes.md
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file release-notes.md
else
gh release create "$RELEASE_TAG" --target "${GITHUB_SHA}" --title "$RELEASE_TAG" --notes-file release-notes.md
fi
+32
View File
@@ -0,0 +1,32 @@
name: Build Validation
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v24.14.0
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
- name: install Dependencies
run: npm install
- name: run Build Validation
run: npm run check
- name: run Tests
run: npm run test
env:
CHROME_BIN: google-chrome
+2 -2
View File
@@ -3,7 +3,7 @@
## Project Context ## Project Context
This is an Angular portfolio application using Angular 21, TypeScript, SCSS, This is an Angular portfolio application using Angular 21, TypeScript, SCSS,
Vitest tests, and static hosting for production output. Production Karma/Jasmine tests, and static hosting for production output. Production
application code lives in `src/app`; the client entry point lives in application code lives in `src/app`; the client entry point lives in
`src/main.ts`. `src/main.ts`.
@@ -32,7 +32,7 @@ maintain generated or copied output in `public/assets/optimized` or `dist`.
- `npm run build`: generates assets, builds the production app, and copies - `npm run build`: generates assets, builds the production app, and copies
static root files. Run this only when explicitly requested. static root files. Run this only when explicitly requested.
- `npm start`: serves the built static app through `scripts/static-server.mjs`. - `npm start`: serves the built static app through `scripts/static-server.mjs`.
- `npm test`: generates assets and starts Angular tests with Vitest. - `npm test`: generates assets and starts Angular tests with Karma/Jasmine.
- `npm run check`: runs linting, format checks, version checks, and source text - `npm run check`: runs linting, format checks, version checks, and source text
checks. Always use this as the final validation. checks. Always use this as the final validation.
- `npm run fix`: places version data, runs ESLint fixes, fixes source text, and - `npm run fix`: places version data, runs ESLint fixes, fixes source text, and
+4 -4
View File
@@ -1,7 +1,7 @@
ARG VCS_REF=unknown ARG VCS_REF=unknown
ARG APP_VERSION=unknown ARG APP_VERSION=unknown
FROM node:24.15.0-bookworm-slim AS build FROM node:24.14.0-bookworm-slim AS build
WORKDIR /app WORKDIR /app
ENV HUSKY=0 ENV HUSKY=0
@@ -12,7 +12,7 @@ RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:24.15.0-bookworm-slim AS runtime FROM node:24.14.0-bookworm-slim AS runtime
ARG VCS_REF ARG VCS_REF
ARG APP_VERSION ARG APP_VERSION
@@ -21,11 +21,11 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
LABEL org.opencontainers.image.title="Personal Portfolio Website" 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.description="👋 My portfolio website built with Angular v21 — a concise introduction to who I am and what I build."
LABEL org.opencontainers.image.licenses="GPL-3.0-only" LABEL org.opencontainers.image.licenses="GPL-3.0-only"
LABEL org.opencontainers.image.version="${APP_VERSION}" LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.revision="${VCS_REF}" LABEL org.opencontainers.image.revision="${VCS_REF}"
LABEL org.opencontainers.image.source="https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website" LABEL org.opencontainers.image.source="https://github.com/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 COPY --chown=node:node scripts/static-server.mjs ./scripts/static-server.mjs
+38 -47
View File
@@ -1,75 +1,66 @@
# FrauJulian's Portfolio Website # FrauJulian's Portfolio Website
## Preview ### [Website Preview](https://fraujulian.xyz/)
Visit the live website at [fraujulian.xyz](https://fraujulian.xyz/). <img width="2551" height="1175" alt="Portfolio website preview" src="https://github.com/user-attachments/assets/24657c0a-4f95-4573-8472-092984d6cc4e" />
## Technology ## 👂 Languages / Framework / Packages
- Angular 22 - Angular v21
- TypeScript - TypeScript
- SCSS - SCSS
- Vitest for tests
## Features ## 💻 Development
- English and German language options ### Development Preview
- About, work, and project portfolio
- Contact links and downloadable OpenPGP public key
- 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
``` ```
The development server generates required static assets and runs the site at ### Building
`http://localhost:4200`.
### Production
```bash ```bash
npm run build npm run build
npm start
``` ```
The build writes the static site to `dist/browser`. The static server serves the This will compile your project and store the build artifacts in the `dist/`
built site at `http://localhost:3000`. directory. The production build optimizes your application for performance and
speed.
## Tests ### Start compiled Project
```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
``` ```
This generates assets and runs the Vitest tests in jsdom. ## 🤝 Enjoy?
## Docs ~ made by [**FrauJulian**](https://fraujulian.xyz/).
- [Nginx Proxy Manager deployment notes](npm.md) Give it a star ⭐ on [GitHub](https://github.com/FrauJulian/Personal-Portfolio-Website)!
## Guidelines ### Greetings from Austria! ⛰️
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).
+22 -6
View File
@@ -15,7 +15,7 @@
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": { "outputPath": {
"base": "dist", "base": "dist",
@@ -108,7 +108,7 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": { "options": {
"port": 4200 "port": 4200
}, },
@@ -123,14 +123,30 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular-devkit/build-angular:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"buildTarget": "::development", "watch": false,
"browsers": "ChromeHeadless",
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"watch": false "inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets/optimized",
"output": "assets/optimized"
},
{
"glob": "**/*",
"input": "public",
"output": "public"
}
],
"styles": ["src/styles.scss"],
"scripts": []
} }
} }
} }
+12927 -4643
View File
File diff suppressed because it is too large. Load diff
+40 -32
View File
@@ -3,7 +3,7 @@
"version": "YYDDDhhmm+GIT_HASH", "version": "YYDDDhhmm+GIT_HASH",
"currentVersion": "3.2.0", "currentVersion": "3.2.0",
"engines": { "engines": {
"node": ">=24.15.0", "node": ">=24.14.0",
"npm": ">=11.12.0" "npm": ">=11.12.0"
}, },
"scripts": { "scripts": {
@@ -45,8 +45,8 @@
"prettier --write" "prettier --write"
] ]
}, },
"repository": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website", "repository": "https://github.com/FrauJulian/Personal-Portfolio-Website",
"bugs": "https://git.lechner-systems.at/fraujulian/Personal-Portfolio-Website/issues", "bugs": "https://github.com/FrauJulian/Personal-Portfolio-Website/issues",
"author": { "author": {
"name": "Lechner Julian", "name": "Lechner Julian",
"nickname": "FrauJulian", "nickname": "FrauJulian",
@@ -55,48 +55,56 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^22.2.1", "@angular/animations": "^21.2.13",
"@angular/cdk": "^22.2.1", "@angular/cdk": "^21.2.11",
"@angular/common": "^22.2.1", "@angular/common": "^21.2.13",
"@angular/compiler": "^22.2.1", "@angular/compiler": "^21.2.13",
"@angular/core": "^22.2.1", "@angular/core": "^21.2.13",
"@angular/forms": "^22.2.1", "@angular/forms": "^21.2.13",
"@angular/material": "^22.2.1", "@angular/material": "^21.2.11",
"@angular/platform-browser": "^22.2.1", "@angular/platform-browser": "^21.2.13",
"@angular/platform-browser-dynamic": "^22.2.1", "@angular/platform-browser-dynamic": "^21.2.13",
"@angular/platform-server": "^22.2.1", "@angular/platform-server": "^21.2.13",
"@angular/router": "^22.2.1", "@angular/router": "^21.2.13",
"@angular/ssr": "^22.2.1", "@angular/ssr": "^21.2.11",
"@types/markdown-it": "^14.1.2",
"express": "^5.2.1", "express": "^5.2.1",
"markdown-it": "^15.0.2", "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/build": "^22.2.1", "@angular-devkit/build-angular": "^21.2.11",
"@angular/cli": "^22.2.1", "@angular/cli": "^21.2.11",
"@angular/compiler-cli": "^22.2.1", "@angular/compiler-cli": "^21.2.13",
"@types/express": "^5.0.6", "@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", "@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": "10.4.0",
"eslint-import-resolver-typescript": "^4.4.5", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "^18.4.1", "eslint-plugin-n": "18.0.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",
"jsdom": "^30.1.2", "jasmine-core": "~6.2.0",
"postcss": "^8.5.29", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.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.35.5", "sharp": "^0.34.5",
"typescript": "~6.0.3", "typescript": "~5.9.3",
"typescript-eslint": "8.71.1", "typescript-eslint": "8.59.3"
"vitest": "^5.0.3"
} }
} }
+6 -8
View File
@@ -31,9 +31,7 @@ describe('AppComponent', (): void => {
}); });
it('should preselect English by default', (): void => { it('should preselect English by default', (): void => {
const comp = component as unknown as { const comp = component as unknown as { selectedLanguage(): string };
selectedLanguage(): string;
};
expect(comp.selectedLanguage()).toBe('en'); expect(comp.selectedLanguage()).toBe('en');
}); });
@@ -59,8 +57,8 @@ describe('AppComponent', (): void => {
fixture.detectChanges(); fixture.detectChanges();
expect(comp.currentLanguageCode()).toBe('de'); expect(comp.currentLanguageCode()).toBe('de');
expect(comp.isLanguageConfirmed()).toBe(true); 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');
}); });
@@ -76,8 +74,8 @@ describe('AppComponent', (): void => {
}; };
expect(restoredComponent.currentLanguageCode()).toBe('de'); expect(restoredComponent.currentLanguageCode()).toBe('de');
expect(restoredComponent.isLanguageConfirmed()).toBe(true); 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 => {
@@ -91,6 +89,6 @@ describe('AppComponent', (): void => {
comp.reopenLanguageSelector(); comp.reopenLanguageSelector();
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLanguageSelectorOpen()).toBe(true); expect(comp.isLanguageSelectorOpen()).toBeTrue();
}); });
}); });
+1 -3
View File
@@ -21,9 +21,7 @@
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile" <a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
><app-icon [icon]="discordIcon" size="lg"></app-icon ><app-icon [icon]="discordIcon" size="lg"></app-icon
></a> ></a>
<a href="https://git.lechner-systems.at/fraujulian" aria-label="Gitea profile" <a href="https://github.com/fraujulian" aria-label="GitHub profile"><app-icon [icon]="githubIcon" size="lg"></app-icon></a>
><app-icon [icon]="githubIcon" size="lg"></app-icon
></a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile" <a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
><app-icon [icon]="linkedinIcon" size="lg"></app-icon ><app-icon [icon]="linkedinIcon" size="lg"></app-icon
></a> ></a>
+84 -96
View File
@@ -1,6 +1,5 @@
import type { Mock } from 'vitest';
import type { ComponentFixture } from '@angular/core/testing'; import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing'; import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { IMAGE_LOADER } from '@angular/common'; import { IMAGE_LOADER } from '@angular/common';
import type { ImageLoaderConfig } from '@angular/common'; import type { ImageLoaderConfig } from '@angular/common';
@@ -15,28 +14,6 @@ import type {
} from '../../languages/language.types'; } 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;
@@ -86,7 +63,8 @@ describe('HomeComponent', (): void => {
afterEach((): void => { afterEach((): void => {
// Explicitly destroy to stop the interval(1000) subscription started in ngOnInit. // Explicitly destroy to stop the interval(1000) subscription started in ngOnInit.
// Without this, the interval keeps firing after the test has finished. // Without this, the real setInterval keeps the browser's event loop alive after
// the test suite completes, causing Karma to disconnect with a 30 s timeout.
fixture.destroy(); fixture.destroy();
}); });
@@ -173,7 +151,7 @@ describe('HomeComponent', (): void => {
it('should set isPortraitSwitching to true immediately', (): void => { it('should set isPortraitSwitching to true immediately', (): void => {
comp.isPortraitSwitching = false; comp.isPortraitSwitching = false;
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
expect(comp.isPortraitSwitching).toBe(true); expect(comp.isPortraitSwitching).toBeTrue();
}); });
it('should not advance index when already switching', (): void => { it('should not advance index when already switching', (): void => {
@@ -183,124 +161,129 @@ describe('HomeComponent', (): void => {
}); });
it('should not call setTimeout when only one portrait exists', (): void => { it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = ( const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] })
comp as unknown as { .portraitHighlights;
portraitHighlights: LanguagePortraitHighlight[];
}
).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 = vi.spyOn(window, 'setTimeout'); const setSpy = 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', async (): Promise<void> => { it('should advance the portrait index after 125 ms', fakeAsync((): void => {
const zone = TestBed.inject(NgZone); const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => { zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(125); tick(125);
expect(comp.currentPortraitHighlightIndex).toBe(1); expect(comp.currentPortraitHighlightIndex).toBe(1);
await vi.advanceTimersByTimeAsync(125); tick(125);
}); discardPeriodicTasks();
}));
it('should reset isPortraitSwitching to false after 250 ms', async (): Promise<void> => { it('should reset isPortraitSwitching to false after 250 ms', fakeAsync((): void => {
const zone = TestBed.inject(NgZone); const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => { zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(250); tick(250);
expect(comp.isPortraitSwitching).toBe(false); expect(comp.isPortraitSwitching).toBeFalse();
}); discardPeriodicTasks();
}));
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => { it('should wrap portrait index from the last position back to 0', fakeAsync((): void => {
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1; comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
const zone = TestBed.inject(NgZone); const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => { zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(125); tick(125);
expect(comp.currentPortraitHighlightIndex).toBe(0); expect(comp.currentPortraitHighlightIndex).toBe(0);
await vi.advanceTimersByTimeAsync(125); tick(125);
}); discardPeriodicTasks();
}));
}); });
// ── Bio toggle ───────────────────────────────────────────────────────────── // ── Bio toggle ─────────────────────────────────────────────────────────────
describe('toggleBio', (): void => { describe('toggleBio', (): void => {
it('should mount bio immediately on opening', async (): Promise<void> => { it('should mount bio immediately on opening', fakeAsync((): void => {
comp.toggleBio(); comp.toggleBio();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted).toBeTrue();
await vi.advanceTimersByTimeAsync(100); tick(100);
}); discardPeriodicTasks();
}));
it('should set isLongBioShown to true after the double-rAF delay', async (): Promise<void> => { it('should set isLongBioShown to true after the double-rAF delay', fakeAsync((): void => {
comp.toggleBio(); comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute tick(50); // allow both nested requestAnimationFrame calls to execute
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioShown).toBe(true); expect(comp.isLongBioShown).toBeTrue();
}); discardPeriodicTasks();
}));
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => { it('should set isLongBioShown to false immediately on close', fakeAsync((): void => {
// Open first // Open first
comp.toggleBio(); comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); tick(50);
fixture.detectChanges(); fixture.detectChanges();
// Close // Close
comp.toggleBio(); comp.toggleBio();
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioShown).toBe(false); expect(comp.isLongBioShown).toBeFalse();
await vi.advanceTimersByTimeAsync(300); tick(300);
}); discardPeriodicTasks();
}));
it('should keep isLongBioMounted true during the 300 ms close animation', async (): Promise<void> => { it('should keep isLongBioMounted true during the 300 ms close animation', fakeAsync((): void => {
comp.toggleBio(); comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); tick(50);
fixture.detectChanges(); fixture.detectChanges();
comp.toggleBio(); comp.toggleBio();
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted).toBeTrue();
await vi.advanceTimersByTimeAsync(299); tick(299);
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted).toBeTrue();
await vi.advanceTimersByTimeAsync(1); tick(1);
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioMounted).toBe(false); expect(comp.isLongBioMounted).toBeFalse();
}); discardPeriodicTasks();
}));
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => { it('should cancel pending close timeout when re-opened during animation', fakeAsync((): void => {
comp.toggleBio(); comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); tick(50);
fixture.detectChanges(); fixture.detectChanges();
comp.toggleBio(); // start closing comp.toggleBio(); // start closing
await vi.advanceTimersByTimeAsync(150); // halfway through 300 ms tick(150); // halfway through 300 ms
comp.toggleBio(); // re-open before unmount comp.toggleBio(); // re-open before unmount
await vi.advanceTimersByTimeAsync(50); tick(50);
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted).toBeTrue();
expect(comp.isLongBioShown).toBe(true); expect(comp.isLongBioShown).toBeTrue();
await vi.advanceTimersByTimeAsync(500); tick(500);
}); discardPeriodicTasks();
}));
}); });
// ── Scroll helpers ───────────────────────────────────────────────────────── // ── Scroll helpers ─────────────────────────────────────────────────────────
describe('scroll helpers', (): void => { describe('scroll helpers', (): void => {
let scrollSpy: Mock; let scrollSpy: jasmine.Spy;
beforeEach((): void => { beforeEach((): void => {
scrollSpy = vi.spyOn(window, 'scrollTo').mockReturnValue(undefined); scrollSpy = spyOn(window, 'scrollTo');
}); });
it('should call window.scrollTo when scrolling to the about section', (): void => { it('should call window.scrollTo when scrolling to the about section', (): void => {
@@ -366,18 +349,20 @@ describe('HomeComponent', (): void => {
// ── Lifecycle cleanup ────────────────────────────────────────────────────── // ── Lifecycle cleanup ──────────────────────────────────────────────────────
describe('ngOnDestroy', (): void => { describe('ngOnDestroy', (): void => {
it('should clear pending portrait switch timers on destroy', async (): Promise<void> => { it('should clear pending portrait switch timers on destroy', fakeAsync((): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
const clearSpy = vi.spyOn(window, 'clearTimeout'); const clearSpy = spyOn(window, 'clearTimeout').and.callThrough();
fixture.destroy(); fixture.destroy();
expect(clearSpy).toHaveBeenCalled(); expect(clearSpy).toHaveBeenCalled();
}); discardPeriodicTasks();
}));
it('should not throw when destroyed', async (): Promise<void> => { it('should not throw when destroyed', fakeAsync((): void => {
expect((): void => fixture.destroy()).not.toThrow(); expect((): void => fixture.destroy()).not.toThrow();
}); discardPeriodicTasks();
}));
}); });
// ── Template rendering ───────────────────────────────────────────────────── // ── Template rendering ─────────────────────────────────────────────────────
@@ -445,7 +430,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 => {
vi.spyOn(window, 'matchMedia').mockImplementation( spyOn(window, 'matchMedia').and.callFake(
(query: string): MediaQueryList => (query: string): MediaQueryList =>
({ ({
matches: matches:
@@ -454,29 +439,29 @@ describe('HomeComponent', (): void => {
}) as MediaQueryList, }) as MediaQueryList,
); );
expect(comp.shouldEnableScrollEffects()).toBe(true); expect(comp.shouldEnableScrollEffects()).toBeTrue();
}); });
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 => {
vi.spyOn(window, 'matchMedia').mockImplementation( spyOn(window, 'matchMedia').and.callFake(
(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()).toBe(true); expect(comp.shouldEnableScrollEffects()).toBeTrue();
}); });
it('should disable scroll effects when reduced motion is requested', (): void => { it('should disable scroll effects when reduced motion is requested', (): void => {
vi.spyOn(window, 'matchMedia').mockImplementation( spyOn(window, 'matchMedia').and.callFake(
(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()).toBe(false); expect(comp.shouldEnableScrollEffects()).toBeFalse();
}); });
it('should create and run initial change detection within 200 ms', (): void => { it('should create and run initial change detection within 200 ms', (): void => {
@@ -495,13 +480,13 @@ 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', async (): Promise<void> => { it('should deduplicate rapid schedule calls to a single requestAnimationFrame', fakeAsync((): void => {
// The RAF registered during beforeEach's ngOnInit is still pending under fake timers. // The RAF registered during beforeEach's ngOnInit lives outside this fakeAsync zone.
// Resetting the ID to null makes the schedule function treat the slot as free. // Resetting the ID to null makes the schedule function treat the slot as free.
comp.scrollAnimationFrameId = null; comp.scrollAnimationFrameId = null;
let rafCallCount = 0; let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++; rafCallCount++;
return rafCallCount; // returns non-null so subsequent calls deduplicate return rafCallCount; // returns non-null so subsequent calls deduplicate
}); });
@@ -511,13 +496,14 @@ describe('HomeComponent', (): void => {
comp.scheduleNameGradientUpdate(); comp.scheduleNameGradientUpdate();
expect(rafCallCount).toBe(1); expect(rafCallCount).toBe(1);
}); discardPeriodicTasks();
}));
it('should deduplicate project-entry reveal scheduling the same way', async (): Promise<void> => { it('should deduplicate project-entry reveal scheduling the same way', fakeAsync((): void => {
comp.projectEntryAnimationFrameId = null; comp.projectEntryAnimationFrameId = null;
let rafCallCount = 0; let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++; rafCallCount++;
return rafCallCount; return rafCallCount;
}); });
@@ -527,13 +513,14 @@ describe('HomeComponent', (): void => {
comp.scheduleProjectEntryRevealUpdate(); comp.scheduleProjectEntryRevealUpdate();
expect(rafCallCount).toBe(1); expect(rafCallCount).toBe(1);
}); discardPeriodicTasks();
}));
it('should deduplicate about-section scroll scheduling the same way', async (): Promise<void> => { it('should deduplicate about-section scroll scheduling the same way', fakeAsync((): void => {
comp.aboutSectionAnimationFrameId = null; comp.aboutSectionAnimationFrameId = null;
let rafCallCount = 0; let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { spyOn(window, 'requestAnimationFrame').and.callFake((): number => {
rafCallCount++; rafCallCount++;
return rafCallCount; return rafCallCount;
}); });
@@ -543,6 +530,7 @@ describe('HomeComponent', (): void => {
comp.scheduleAboutSectionScrollAnimationUpdate(); comp.scheduleAboutSectionScrollAnimationUpdate();
expect(rafCallCount).toBe(1); expect(rafCallCount).toBe(1);
}); discardPeriodicTasks();
}));
}); });
}); });
+1 -1
View File
@@ -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)).toBe(true); expect(headings.some((h): boolean => h.textContent?.includes('Imprint') === true)).toBeTrue();
}); });
it('should contain a back link to the portfolio root', (): void => { it('should contain a back link to the portfolio root', (): void => {
+1 -1
View File
@@ -222,7 +222,7 @@ export const deLanguage: LanguagePack = {
}, },
{ {
title: 'Discord Audio Stream Library', title: 'Discord Audio Stream Library',
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream', link: 'https://github.com/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'], skills: ['TypeScript', 'YML'],
+1 -1
View File
@@ -222,7 +222,7 @@ export const enLanguage: LanguagePack = {
}, },
{ {
title: 'Discord Audio Stream Library', title: 'Discord Audio Stream Library',
link: 'https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream', link: 'https://github.com/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'], skills: ['TypeScript', 'YML'],
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/out-tsc/spec", "outDir": "./dist/out-tsc/spec",
"types": ["vitest/globals", "node"] "types": ["jasmine", "node"]
}, },
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"] "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
} }