104 Commits

Author SHA1 Message Date
3132aab2a5 Release version 1.1.0 2026-03-30 10:47:32 +02:00
3d1db1f8ba Updated mirrors 2026-03-30 10:46:39 +02:00
58872ced81 fix(ci): grant security-events: write to lint job
The lint-docker job in lint.yml requires security-events: write
for SARIF upload; must be explicitly granted to the caller job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:18:14 +02:00
13b3af3330 Entered and removed whitespaces in README.md 2026-03-30 10:16:53 +02:00
eca7084f4e fix(ci): grant security-events and packages permissions to security job
Reusable workflow calls inherit only explicitly granted permissions.
The nested security job requires packages: read and security-events: write
for CodeQL analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:16:30 +02:00
6861b2c0eb fix(nav): prevent navbar items from wrapping to second line
- Set flex-wrap: nowrap on navbar-nav to keep all items in one row
- Add hidden overflow-x scroll (no visible scrollbar) as fallback
- Fix #navbar_logo taking up space when invisible via max-width transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:55 +02:00
66b1f0d029 feat(agents): add AGENTS.md and CLAUDE.md with pre-commit rules
- Add AGENTS.md: require make test before every non-doc commit and
  document the npm vendor asset workflow
- Add CLAUDE.md: instruct agents to read AGENTS.md at conversation start
- Add npm-install dependency to test-e2e Makefile target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:59:09 +02:00
a29a0b1862 feat(vendor): replace CDN dependencies with local npm packages
Introduces a vendor build pipeline so all third-party browser assets
(Bootstrap, Bootstrap Icons, Font Awesome, marked, jQuery) are served
from local static files instead of external CDNs.

- Add app/package.json with vendor deps and postinstall/build scripts
- Add app/scripts/copy-vendor.js to copy assets to static/vendor/
- Update base.html.j2 to use url_for('static', ...) for all vendor assets
- Update Dockerfile to install Node.js/npm and run npm install
- Update .gitignore to exclude app/node_modules/ and app/static/vendor/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:28 +02:00
252b50d2a7 feat: migrate to pyproject.toml, add test suites, split CI workflows
- Replace requirements.txt with pyproject.toml for modern Python packaging
- Add unit, integration, lint and security test suites under tests/
- Add utils/export_runtime_requirements.py and utils/check_hadolint_sarif.py
- Split monolithic CI into reusable lint.yml, security.yml and tests.yml
- Refactor ci.yml to orchestrate reusable workflows; publish on semver tag only
- Modernize Dockerfile: pin python:3.12-slim, install via pyproject.toml
- Expand Makefile with lint, security, test and CI targets
- Add test-e2e via act with portfolio container stop/start around run
- Fix navbar_logo_visibility.spec.js: win.fullscreen() → win.enterFullscreen()
- Set use_reloader=False in app.run() to prevent double-start in CI
- Add app/core.* and build artifacts to .gitignore
- Fix apt-get → sudo apt-get in tests.yml e2e job
- Fix pip install --ignore-installed to handle stale act cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 23:03:09 +02:00
2c61da9fc3 chore: remove comments from settings.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:10 +01:00
2d8185b747 chore: add Claude Code project permissions settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:16:29 +01:00
a47a5babce chore: remove unused imports 2026-03-28 19:08:57 +01:00
3f6c90cc3c Release version 1.0.0 2026-02-19 11:18:50 +01:00
69c4f15ce7 Prepare config.yaml from sample in CI before Cypress 2026-02-19 11:14:52 +01:00
56c1b0d0cd Fix Cypress action for repositories without lockfile 2026-02-19 11:13:12 +01:00
91e9caea48 Fix CI node cache path and npm install strategy 2026-02-19 11:11:11 +01:00
feb6af28ef Add CI workflow for tests and conditional image publish 2026-02-19 11:05:26 +01:00
f8c2b4236b Added d-flex to place logo next to brand 2025-07-21 14:24:28 +02:00
dc2626e020 Added test for log 2025-07-21 12:18:07 +02:00
46b0b744ca Added logo to navbar when in fullscreen 2025-07-21 11:39:59 +02:00
5f2e7ef696 Changed pkgmgr commands 2025-07-12 18:54:42 +02:00
152a85bfb8 Merge branch 'main' of github.com:kevinveenbirkenbach/portfolio 2025-07-12 18:53:03 +02:00
fdfe301868 Renamed PortWebUI to PortUI 2025-07-12 18:52:51 +02:00
cbfe1ed8ae Update README.md
Solved another layout bug
2025-07-12 18:27:47 +02:00
9470162236 Solved formatation bug 2025-07-12 18:26:51 +02:00
6a57fa1e00 Optimized README.md 2025-07-12 18:24:49 +02:00
ab67fc0b29 Renamed portfolio to PortWebUI 2025-07-12 18:22:19 +02:00
e18566d801 Solved some bugs 2025-07-09 22:20:58 +02:00
7bc0f32145 Added cypress tests 2025-07-08 17:16:57 +02:00
6ed3e60dd0 Solved 2tap fullscreen hight bug 2025-07-08 14:39:13 +02:00
ab8ea0dbd6 Added iframe observer 2025-07-07 23:40:35 +02:00
b0446dcd29 Added include svgs 2025-07-07 19:14:29 +02:00
55d309b2d7 Changed fade between html iframe animation 2025-07-07 15:37:24 +02:00
d99a8c8452 Added restore functionality to small logo 2025-07-07 15:06:36 +02:00
3f6a195ecb Optimizid hover 2025-07-07 13:37:02 +02:00
430ea4a120 Solved loading bug 2025-07-07 13:19:49 +02:00
cc0fc9b77f Replaced object by svg 2025-07-07 12:46:56 +02:00
9ada9acb3a Implemented SVG handling 2025-07-07 12:40:25 +02:00
246ef1b059 Added backup logik for missing images 2025-07-07 08:57:30 +02:00
6572a39d48 Added hover effects to cards 2025-07-06 22:25:22 +02:00
a80262c0d4 Solved container height bug 2025-07-06 22:18:28 +02:00
531c2295bd Added header/footer animation 2025-07-06 18:14:42 +02:00
0640ec6439 Added animation for fullscreen log 2025-07-06 17:46:51 +02:00
a7eb14046f Added fullwidth animation 2025-07-06 17:35:40 +02:00
539580ad09 Added missing enterfullscreen function 2025-07-06 17:13:15 +02:00
faf5bd1e8c Solved iframe margin bug 2025-07-06 10:55:12 +02:00
97378422bd Solved more CSS bugs 2025-07-05 21:07:31 +02:00
2632c21de3 Removed unnecessary log messages 2025-07-05 20:33:08 +02:00
64db9a4e6a Implemented Nasa Picture of the day 2025-07-05 20:08:00 +02:00
d0f8d7d172 Added logo for small screen 2025-07-05 18:54:18 +02:00
20b6c731b8 Added onclick functionality for menu items 2025-07-05 18:32:26 +02:00
2f63009c31 Implemented full width function 2025-07-05 18:00:23 +02:00
f0d4206731 Finished resize implementation for iframe 2025-07-05 16:53:25 +02:00
b8aad8b695 Removed non functional resize code 2025-07-05 14:39:18 +02:00
697696347f finished fullscreen base implementation 2025-07-05 14:20:52 +02:00
d6389157ec Added fullscreen mode 2025-07-05 13:30:25 +02:00
25dbc3f331 Added correct iframe size loading 2025-07-05 13:17:38 +02:00
bb8799eb8a Added functionality for iframe url 2025-07-05 11:54:20 +02:00
86fd72b623 Solved wrong environment bug 2025-07-05 11:41:18 +02:00
9c24a8658f Solved other port mapping issues 2025-07-05 11:06:42 +02:00
5fc19f6ccb Solved other port bugs 2025-07-05 10:55:32 +02:00
35bfeeb51e Added correct env path import 2025-07-05 10:12:49 +02:00
dfbc840c69 Optimized default port 2025-07-05 09:40:11 +02:00
1bea9703ea Added port via env 2025-07-05 09:32:07 +02:00
4d68ed2a24 Solved caching bug 2025-07-01 23:28:25 +02:00
a0c7a7e8ca Added exception for debugging 2025-04-10 14:01:53 +02:00
3ec92ff853 Update README.md 2025-04-08 18:12:08 +02:00
8cb2f578df Added --delete and browse 2025-03-21 18:36:00 +01:00
412a7bae16 Added helper functions for portfolio 2025-03-20 00:23:35 +01:00
8e280de139 Added header h1 pointer 2025-03-19 17:20:08 +01:00
19f47a82fa Implemented iframe logic for modals 2025-03-19 17:16:44 +01:00
3b4dc298f8 Rafactored iframe.js 2025-03-19 16:53:49 +01:00
79e10e97b7 Marked header h1 as clickable 2025-03-19 16:16:27 +01:00
f5a9838474 Added logic for reload via header 2025-03-19 16:14:06 +01:00
242d1b9948 Implemented iframes for menu items and imprint 2025-03-19 15:54:19 +01:00
3db9872791 deleted test file 2025-03-19 15:48:34 +01:00
6a0db00f24 Modified scrollbars for iframes 2025-03-18 15:03:03 +01:00
3529749df5 Added iframe draft 2025-03-18 14:59:54 +01:00
ae775916b0 Replaced German by English comments 2025-03-18 14:27:07 +01:00
45969feaed Refactored card logic 2025-03-18 14:10:30 +01:00
464d307ee8 Optimized nav corners 2025-03-18 14:03:19 +01:00
4aceb2ed62 Solved main shadow bug 2025-03-18 13:56:42 +01:00
a8a2efd091 Solved scrollbar issues 2025-03-18 13:49:35 +01:00
3284684282 Solved main size bug 2025-03-18 13:30:38 +01:00
20c4a4809b Refactored css 2025-03-18 13:10:35 +01:00
898f7479c9 Added scrollbar draft 2025-03-18 12:50:14 +01:00
56513230e4 Implemented flexible card box sizes depending on card box amount 2025-03-18 03:56:37 +01:00
c35f44baef Added Funding 2025-03-12 20:52:48 +01:00
ef7059e748 Solved title bug 2025-03-12 11:14:40 +01:00
6597fb2862 Implemented better differenciation between platform and company 2025-02-19 23:47:34 +01:00
6ba6b2ea99 Implemented to set just class for cards 2025-02-19 22:41:41 +01:00
94b4e1f883 Added support for css logos 2025-02-19 20:53:05 +01:00
e03e740149 Solved bug 2025-01-17 10:56:45 +01:00
c96702035f Updated README.md 2025-01-17 10:34:21 +01:00
dc11dc799b Optimized path mapping 2025-01-17 02:14:48 +01:00
8c7dc02bd5 Updated README.md 2025-01-17 02:12:09 +01:00
9741da0495 Refactored modal.html.j2 2025-01-17 01:13:39 +01:00
0f8113974f Refactored warning and info js 2025-01-17 01:07:48 +01:00
a0664691e6 Refactored alternatives and options js 2025-01-17 01:04:27 +01:00
0360c443b7 Solved childrens selector bug 2025-01-17 00:59:26 +01:00
954cff051a Added children 2025-01-17 00:44:28 +01:00
7f78e77a10 Updated README.md 2025-01-16 23:12:49 +01:00
1c6b70d640 Updated README.md 2025-01-16 22:56:02 +01:00
f664270b5d renamed to config.sample.yaml 2025-01-16 22:52:58 +01:00
72 changed files with 3888 additions and 423 deletions

89
.claude/settings.json Normal file
View File

@@ -0,0 +1,89 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Bash(git status*)",
"Bash(git log*)",
"Bash(git diff*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git checkout*)",
"Bash(git branch*)",
"Bash(git fetch*)",
"Bash(git stash*)",
"Bash(git -C:*)",
"Bash(make*)",
"Bash(python3*)",
"Bash(python*)",
"Bash(pip show*)",
"Bash(pip list*)",
"Bash(pip install*)",
"Bash(npm install*)",
"Bash(npm run*)",
"Bash(npx*)",
"Bash(docker pull*)",
"Bash(docker build*)",
"Bash(docker images*)",
"Bash(docker ps*)",
"Bash(docker inspect*)",
"Bash(docker logs*)",
"Bash(docker create*)",
"Bash(docker export*)",
"Bash(docker rm*)",
"Bash(docker rmi*)",
"Bash(docker stop*)",
"Bash(docker compose*)",
"Bash(docker-compose*)",
"Bash(docker container prune*)",
"Bash(grep*)",
"Bash(find*)",
"Bash(ls*)",
"Bash(cat*)",
"Bash(head*)",
"Bash(tail*)",
"Bash(wc*)",
"Bash(sort*)",
"Bash(tar*)",
"Bash(mkdir*)",
"Bash(cp*)",
"Bash(mv*)",
"Bash(jq*)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:api.github.com)",
"WebFetch(domain:docs.docker.com)",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:docs.cypress.io)",
"WebFetch(domain:flask.palletsprojects.com)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(rm -rf*)",
"Bash(sudo*)"
]
},
"sandbox": {
"filesystem": {
"allowWrite": [
".",
"/tmp"
],
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.kube",
"~/.aws",
"~/.config/gcloud"
]
}
}
}

7
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
github: kevinveenbirkenbach
patreon: kevinveenbirkenbach
buy_me_a_coffee: kevinveenbirkenbach
custom: https://s.veen.world/paypaldonate

90
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: CI
on:
pull_request:
push:
branches:
- "**"
tags-ignore:
- "**"
permissions:
contents: read
jobs:
security:
name: Run security workflow
uses: ./.github/workflows/security.yml
permissions:
contents: read
packages: read
security-events: write
tests:
name: Run test workflow
uses: ./.github/workflows/tests.yml
lint:
name: Run lint workflow
uses: ./.github/workflows/lint.yml
permissions:
contents: read
security-events: write
publish:
name: Publish image
runs-on: ubuntu-latest
needs:
- security
- tests
- lint
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect semver tag on current commit
id: semver
run: |
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
if [ -n "$SEMVER_TAG" ]; then
{
echo "found=true"
echo "raw_tag=$SEMVER_TAG"
echo "version=${SEMVER_TAG#v}"
} >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Compute image name
if: steps.semver.outputs.found == 'true'
id: image
run: echo "name=ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
if: steps.semver.outputs.found == 'true'
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: steps.semver.outputs.found == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish image
if: steps.semver.outputs.found == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.image.outputs.name }}:${{ steps.semver.outputs.version }}

77
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Lint
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
lint-actions:
name: Lint GitHub Actions
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
lint-python:
name: Lint Python
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
lint-docker:
name: Lint Dockerfile
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run hadolint
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
with:
dockerfile: ./Dockerfile
format: sarif
output-file: hadolint-results.sarif
failure-threshold: warning
- name: Upload hadolint SARIF
if: always() && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: hadolint-results.sarif
wait-for-processing: true
category: hadolint
- name: Fail on hadolint warnings
if: always()
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif

48
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Security
on:
workflow_call:
permissions:
contents: read
jobs:
analyze:
name: Run security scan
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Run manual build steps
if: matrix.build-mode == 'manual'
run: |
echo "No manual build is configured for this repository."
exit 1
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}

194
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,194 @@
name: Tests
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
test-lint:
name: Run lint tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Run lint test suite
run: python -m unittest discover -s tests/lint -t .
test-integration:
name: Run integration tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install integration test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run integration test suite
run: python -m unittest discover -s tests/integration -t .
test-unit:
name: Run unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install unit test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run unit test suite
run: python -m unittest discover -s tests/unit -t .
security-python:
name: Run Python security checks
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install security dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed ".[dev]"
- name: Run Bandit
run: python -m bandit -q -ll -ii -r app main.py
- name: Export runtime requirements
run: python utils/export_runtime_requirements.py > runtime-requirements.txt
- name: Audit Python runtime dependencies
run: python -m pip_audit -r runtime-requirements.txt
test-security:
name: Run security guardrail tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install security test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run security test suite
run: python -m unittest discover -s tests/security -t .
e2e:
name: Run end-to-end tests
runs-on: ubuntu-latest
needs:
- test-lint
- test-unit
- test-integration
- security-python
- test-security
env:
FLASK_HOST: "127.0.0.1"
FLASK_PORT: "5001"
PORT: "5001"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Prepare app config for CI
run: cp app/config.sample.yaml app/config.yaml
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package.json
- name: Install Node dependencies
working-directory: app
run: npm install
- name: Install Cypress system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2t64 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0t64 \
libcups2t64 \
libdrm2 \
libgbm1 \
libglib2.0-0t64 \
libgtk-3-0t64 \
libnotify4 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
libxtst6 \
xauth \
xvfb
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
working-directory: app
install: false
start: python app.py
wait-on: http://127.0.0.1:5001
wait-on-timeout: 120

12
.gitignore vendored
View File

@@ -1,2 +1,12 @@
app/static/cache/* app/config.yaml
*__pycache__* *__pycache__*
app/static/cache/*
.env
app/cypress/screenshots/*
.ruff_cache/
app/node_modules/
app/static/vendor/
hadolint-results.sarif
build/
*.egg-info/
app/core.*

13
AGENTS.md Normal file
View File

@@ -0,0 +1,13 @@
# Agent Instructions
## Pre-Commit Validation
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
- You MUST commit only after all tests pass.
- You MUST NOT commit automatically without explicit confirmation from the user.
## Vendor Assets
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
## [1.1.0] - 2026-03-30
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
## [1.0.0] - 2026-02-19
* Official Release🥳

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
# CLAUDE.md
## Startup
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.

View File

@@ -1,18 +1,20 @@
# Basis-Image für Python FROM python:3.12-slim
FROM python:slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/build
COPY pyproject.toml README.md main.py ./
COPY app ./app
RUN python -m pip install --no-cache-dir .
# Arbeitsverzeichnis festlegen
WORKDIR /app WORKDIR /app
# Abhängigkeiten kopieren und installieren
COPY app/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Anwendungscode kopieren
COPY app/ . COPY app/ .
RUN npm install --prefix /app
# Port freigeben
EXPOSE 5000
# Startbefehl
CMD ["python", "app.py"] CMD ["python", "app.py"]

4
MIRRORS Normal file
View File

@@ -0,0 +1,4 @@
git@github.com:kevinveenbirkenbach/port-ui.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git

174
Makefile Normal file
View File

@@ -0,0 +1,174 @@
# Load environment variables from .env
ifneq (,$(wildcard .env))
include .env
# Export variables defined in .env
export $(shell sed 's/=.*//' .env)
endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
PYTHON ?= python3
ACT ?= act
# Default port (can be overridden with PORT env var)
.PHONY: build
build:
# Build the Docker image.
docker build -t application-portfolio .
.PHONY: build-no-cache
build-no-cache:
# Build the Docker image without cache.
docker build --no-cache -t application-portfolio .
.PHONY: up
up:
# Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate
.PHONY: down
down:
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
- docker stop portfolio || true
- docker rm portfolio || true
- docker-compose down
.PHONY: run-dev
run-dev:
# Run the container in development mode (hot-reload).
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
-v $(PWD)/app/:/app \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
application-portfolio
.PHONY: run-prod
run-prod:
# Run the container in production mode.
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
application-portfolio
.PHONY: logs
logs:
# Display the logs of the 'portfolio' container.
docker logs -f portfolio
.PHONY: dev
dev:
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod:
# Start the application in production mode using docker-compose (with build).
docker-compose up -d --build
.PHONY: cleanup
cleanup:
# Remove all stopped Docker containers to reclaim space.
docker container prune -f
.PHONY: delete
delete:
# Force remove the 'portfolio' container if it exists.
- docker rm -f portfolio
.PHONY: browse
browse:
# Open the application in the browser at http://localhost:$(PORT)
chromium http://localhost:$(PORT)
.PHONY: install
install:
# Install runtime Python dependencies from pyproject.toml.
$(PYTHON) -m pip install -e .
.PHONY: install-dev
install-dev:
# Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]"
.PHONY: npm-install
npm-install:
# Install Node.js dependencies for browser tests.
cd app && npm install
.PHONY: lint-actions
lint-actions:
# Lint GitHub Actions workflows.
docker run --rm -v "$$PWD:/repo" -w /repo rhysd/actionlint:latest
.PHONY: lint-python
lint-python: install-dev
# Run Python lint and format checks.
$(PYTHON) -m ruff check .
$(PYTHON) -m ruff format --check .
.PHONY: lint-docker
lint-docker:
# Lint the Dockerfile.
docker run --rm -i hadolint/hadolint < Dockerfile
.PHONY: test-lint
test-lint:
# Run lint guardrail tests.
$(PYTHON) -m unittest discover -s tests/lint -t .
.PHONY: test-integration
test-integration: install
# Run repository integration tests.
$(PYTHON) -m unittest discover -s tests/integration -t .
.PHONY: test-unit
test-unit: install
# Run repository unit tests.
$(PYTHON) -m unittest discover -s tests/unit -t .
.PHONY: test-security
test-security: install
# Run repository security guardrail tests.
$(PYTHON) -m unittest discover -s tests/security -t .
.PHONY: lint
lint: lint-actions lint-python lint-docker test-lint
# Run the full lint suite.
.PHONY: security
security: install-dev test-security
# Run security checks.
$(PYTHON) -m bandit -q -ll -ii -r app main.py
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: test-e2e
test-e2e: npm-install
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
-docker start portfolio 2>/dev/null || true
.PHONY: test-workflow
test-workflow:
# Run the GitHub test workflow locally via act.
$(ACT) workflow_dispatch -W .github/workflows/tests.yml
.PHONY: lint-workflow
lint-workflow:
# Run the GitHub lint workflow locally via act.
$(ACT) workflow_dispatch -W .github/workflows/lint.yml
.PHONY: quality
quality: lint-workflow test-workflow
# Run the GitHub lint and test workflows locally via act.
.PHONY: ci
ci: lint security test-unit test-integration test-e2e
# Run the local CI suite.
.PHONY: test
test: ci
# Run the full validation suite.

163
README.md
View File

@@ -1,37 +1,158 @@
# Landingpage # PortUI 🖥️✨
## Access [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
### Locale
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)
---
## ✨ Key Features
- **Dynamic Navigation**
Create dropdowns & nested menus with ease.
- **Customizable Cards**
Highlight skills, projects, or services—with icons, titles, and links.
- **Smart Cache Management**
Auto-cache assets for lightning-fast loading.
- **Responsive Design**
Built on Bootstrap; looks great on desktop, tablet & mobile.
- **YAML-Driven**
All content & structure defined in a simple `config.yaml`.
- **CLI Control**
Manage Docker containers via the `portfolio` command.
---
## 🌐 Quick Access
- **Local Preview:**
[http://127.0.0.1:5000](http://127.0.0.1:5000) [http://127.0.0.1:5000](http://127.0.0.1:5000)
---
## Administrate Docker ## 🏁 Getting Started
### Stop and Destroy
### 🔧 Prerequisites
- Docker & Docker Compose
- Basic Python & YAML knowledge
### 🛠️ Installation via Git
1. **Clone & enter repo**
```bash ```bash
docker stop landingpage git clone <repository_url>
docker rm landingpage cd <repository_directory>
``` ```
### Build 2. **Configure**
Copy `config.sample.yaml` → `config.yaml` & customize.
3. **Build & run**
```bash ```bash
docker build -t application-landingpage . docker-compose up --build
```
4. **Browse**
Open [http://localhost:5000](http://localhost:5000)
### 📦 Installation via Kevins Package Manager
```bash
pkgmgr install portui
``` ```
### Run Once installed, the `portui` CLI is available system-wide.
---
## 🖥️ CLI Commands
#### Run Development Environment
```bash ```bash
docker run -d -p 5000:5000 --name landingpage -v $(pwd)/app/:/app -e FLASK_APP=app.py -e FLASK_ENV=development application-landingpage portui --help
``` ```
#### Run Production Environment * `build`Build the Docker image
```bash * `up`Start containers (with build)
docker run -d -p 5000:5000 --name landingpage application-landingpage * `down`Stop & remove containers
* `run-dev`Dev mode (hot-reload)
* `run-prod`Production mode
* `logs`View container logs
* `dev`Docker-Compose dev environment
* `prod`Docker-Compose prod environment
* `cleanup`Prune stopped containers
---
## 🔧 YAML Configuration Guide
Define your sites structure in `config.yaml`:
```yaml
accounts:
name: Online Accounts
description: Discover my online presence.
icon:
class: fa-solid fa-users
children:
- name: Channels
description: Platforms where I share content.
icon:
class: fas fa-newspaper
children:
- name: Mastodon
description: Follow me on Mastodon.
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
identifier: "@kevinveenbirkenbach@microblog.veen.world"
cards:
- icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
url: https://www.agile-coach.world
link_text: www.agile-coach.world
company:
title: Kevin Veen-Birkenbach
subtitle: Consulting & Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_face_512x512/download
favicon:
source: https://cloud.veen.world/s/veen_world_favicon/download
address:
street: Afrikanische Straße 43
postal_code: DE-13351
city: Berlin
country: Germany
imprint_url: https://s.veen.world/imprint
``` ```
### Debug * **`children`** enables multi-level menus.
```bash * **`link`** references other YAML paths to avoid duplication.
docker logs -f landingpage
``` ---
## Author
This software was created from [Kevin Veen-Birkenbach](https://www.veen.world/) with the help of [ChatGPT]() ## 🚢 Production Deployment
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
---
## 📜 License
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
---
## ✍️ Author
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
Enjoy building your portfolio! 🌟

2
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
package-lock.json

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Portfolio UI web application package."""

View File

@@ -1,10 +1,26 @@
import logging
import os import os
from flask import Flask, render_template
import requests import requests
import hashlib
import yaml import yaml
from utils.configuration_resolver import ConfigurationResolver from flask import Flask, current_app, render_template
from markupsafe import Markup
try:
from app.utils.cache_manager import CacheManager
from app.utils.compute_card_classes import compute_card_classes
from app.utils.configuration_resolver import ConfigurationResolver
except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.cache_manager import CacheManager from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver
logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
# Initialize the CacheManager # Initialize the CacheManager
cache_manager = CacheManager() cache_manager = CacheManager()
@@ -12,39 +28,106 @@ cache_manager = CacheManager()
# Clear cache on startup # Clear cache on startup
cache_manager.clear_cache() cache_manager.clear_cache()
def load_config(app):
"""Load and resolve the configuration."""
# Lade die Konfigurationsdatei
with open("config.yaml", "r") as f:
config = yaml.safe_load(f)
# Resolve links in the configuration def load_config(app):
"""Load and resolve the configuration from config.yaml."""
with open("config.yaml", "r", encoding="utf-8") as handle:
config = yaml.safe_load(handle)
if config.get("nasa_api_key"):
app.config["NASA_API_KEY"] = config["nasa_api_key"]
resolver = ConfigurationResolver(config) resolver = ConfigurationResolver(config)
resolver.resolve_links() resolver.resolve_links()
# Update the app configuration
app.config.update(resolver.get_config()) app.config.update(resolver.get_config())
app = Flask(__name__)
load_config(app)
# Hole die Umgebungsvariable FLASK_ENV oder setze einen Standardwert def cache_icons_and_logos(app):
FLASK_ENV = os.getenv("FLASK_ENV", "production") """Cache all icons and logos to local files, with a source fallback."""
for card in app.config["cards"]:
icon = card.get("icon", {})
if icon.get("source"):
cached = cache_manager.cache_file(icon["source"])
icon["cache"] = cached or icon["source"]
company_logo = app.config["company"]["logo"]
cached = cache_manager.cache_file(company_logo["source"])
company_logo["cache"] = cached or company_logo["source"]
favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"]
platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"]
# Initialize Flask app
app = Flask(__name__)
# Load configuration and cache assets on startup
load_config(app)
cache_icons_and_logos(app)
@app.context_processor
def utility_processor():
def include_svg(path):
full_path = os.path.join(current_app.root_path, "static", path)
try:
with open(full_path, "r", encoding="utf-8") as handle:
svg = handle.read()
# Trusted local SVG asset shipped with the application package.
return Markup(svg) # nosec B704
except OSError:
return ""
return dict(include_svg=include_svg)
@app.before_request @app.before_request
def reload_config_in_dev(): def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode."""
if FLASK_ENV == "development": if FLASK_ENV == "development":
load_config(app) load_config(app)
cache_icons_and_logos(app)
# Cache the icons
for card in app.config["cards"]:
card["icon"]["cache"] = cache_manager.cache_file(card["icon"]["source"])
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"]) @app.route("/")
app.config["company"]["favicon"]["cache"] = cache_manager.cache_file(app.config["company"]["favicon"]["source"])
@app.route('/')
def index(): def index():
return render_template("pages/index.html.j2", cards=app.config["cards"], company=app.config["company"], navigation=app.config["navigation"]) """Render the main index page."""
cards = app.config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
apod_bg = None
api_key = app.config.get("NASA_API_KEY")
if api_key:
resp = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": api_key},
timeout=10,
)
if resp.ok:
data = resp.json()
if data.get("media_type") == "image":
apod_bg = data.get("url")
return render_template(
"pages/index.html.j2",
cards=cards,
company=app.config["company"],
navigation=app.config["navigation"],
platform=app.config["platform"],
lg_classes=lg_classes,
md_classes=md_classes,
apod_bg=apod_bg,
)
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=5000) app.run(
debug=(FLASK_ENV == "development"),
host=FLASK_HOST,
port=FLASK_PORT,
use_reloader=False,
)

View File

@@ -1,11 +1,12 @@
--- ---
accounts: accounts:
name: Online Accounts nasa_api_key: YOUR_REAL_KEY_HERE
name: Online Presence
description: Discover my online presence. description: Discover my online presence.
icon: icon:
class: fa-solid fa-users class: fa-solid fa-users
children: children:
- name: Channels - name: Publishing Channels
description: Platforms where I share content. description: Platforms where I share content.
icon: icon:
class: fas fa-newspaper class: fas fa-newspaper
@@ -29,14 +30,14 @@ accounts:
identifier: kevinbirkenbach identifier: kevinbirkenbach
warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon. warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon.
alternatives: alternatives:
- link: accounts.channels.microblogs.mastodon - link: accounts.publishingchannels.microblogs.mastodon
- name: Bluesky - name: Bluesky
description: Follow me on Bluesky (coming soon). description: Follow me on Bluesky (coming soon).
icon: icon:
class: fa-brands fa-bluesky class: fa-brands fa-bluesky
info: Bluesky is coming soon. info: Bluesky is coming soon.
alternatives: alternatives:
- link: accounts.channels.microblogs.mastodon - link: accounts.publishingchannels.microblogs.mastodon
- name: Pictures - name: Pictures
description: View my photography. description: View my photography.
@@ -56,7 +57,7 @@ accounts:
identifier: kevinveenbirkenbach identifier: kevinveenbirkenbach
warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives. warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives.
alternatives: alternatives:
- link: accounts.channels.pictures.pixelfed - link: accounts.publishingchannels.pictures.pixelfed
- name: Videos - name: Videos
description: Watch my video content. description: Watch my video content.
@@ -75,7 +76,7 @@ accounts:
url: https://s.veen.world/youtube url: https://s.veen.world/youtube
warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead. warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead.
alternatives: alternatives:
- link: accounts.channels.videos.peertube - link: accounts.publishingchannels.videos.peertube
- name: Blog - name: Blog
description: Read my articles and stories. description: Read my articles and stories.
@@ -99,17 +100,25 @@ accounts:
class: fa-solid fa-code class: fa-solid fa-code
url: https://git.veen.world/kevinveenbirkenbach url: https://git.veen.world/kevinveenbirkenbach
- name: Social Media - name: Social Networks
description: Social and developer platforms. description: Social and developer platforms.
icon: icon:
class: fa-brands fa-meta class: fa fa-users
children: children:
- name: Facebook - name: Facebook
warning: I recommend to don't use Facebook and connect instead with me via the Fediverse. Check out the listed alternatives.
description: Visit my Facebook page. description: Visit my Facebook page.
icon: icon:
class: fa-brands fa-facebook class: fa-brands fa-facebook
url: https://www.facebook.com/kevinveenbirkenbach url: https://www.facebook.com/kevinveenbirkenbach
alternatives:
- link: accounts.socialnetworks.friendica
- name: Friendica
description: Visit my friendica profile
icon:
class: fas fa-network-wired
url: https://s.veen.world/friendica
identifier: "kevinveenbirkenbach@friendica.veen.world"
- link: navigation.header.contact.messenger - link: navigation.header.contact.messenger
- name: Career Profiles - name: Career Profiles
@@ -121,13 +130,27 @@ accounts:
description: View my XING profile. description: View my XING profile.
icon: icon:
class: bi bi-building class: bi bi-building
url: https://www.xing.com/profile/Kevin_VeenBirkenbach url: https://s.veen.world/xing
- name: LinkedIn - name: LinkedIn
description: Connect with me on LinkedIn. description: Connect with me on LinkedIn.
icon: icon:
class: bi bi-linkedin class: bi bi-linkedin
url: https://www.linkedin.com/in/kevinveenbirkenbach url: https://s.veen.world/linkedin
- name: upwork.com
description: Check out my profile on upwork
icon:
class: fas fa-users
url: https://s.veen.world/upwork
- name: freelancermap.de
description: Check out my profile on freelancermap.de
icon:
class: fas fa-people-arrows
url: https://s.veen.world/freelancermap
- name: malt
description: Check out my profile on malt
icon:
class: fas fa-sun
url: https://s.veen.world/malt
- name: Sports - name: Sports
description: My sports activities and logs. description: My sports activities and logs.
icon: icon:
@@ -168,6 +191,12 @@ accounts:
class: fa-brands fa-discourse class: fa-brands fa-discourse
url: https://forum.veen.world/u/kevinveenbirkenbach url: https://forum.veen.world/u/kevinveenbirkenbach
- name: Nextcloud
description: Share data with me via nextcloud
icon:
class: fa fa-cloud
url: https://s.veen.world/cloud
cards: cards:
- icon: - icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
@@ -177,6 +206,7 @@ cards:
ensuring agile principles are effectively implemented for sustainable success. ensuring agile principles are effectively implemented for sustainable success.
url: https://www.agile-coach.world url: https://www.agile-coach.world
link_text: www.agile-coach.world link_text: www.agile-coach.world
iframe: true
- icon: - icon:
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
title: Personal Coach title: Personal Coach
@@ -236,59 +266,66 @@ cards:
and embrace positive life changes. and embrace positive life changes.
url: https://www.hypno.veen.world url: https://www.hypno.veen.world
link_text: www.hypno.veen.world link_text: www.hypno.veen.world
- icon: #- icon:
source: https://cloud.veen.world/s/logo_skydiver_512x512/download # source: https://cloud.veen.world/s/logo_skydiver_512x512/download
title: Aerospace Consultant # title: Aerospace Consultant
text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot # text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate # License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
I deliver expert consulting services. Currently training for my Private Pilot # I deliver expert consulting services. Currently training for my Private Pilot
License, I specialize in guiding clients through aviation regulations, safety # License, I specialize in guiding clients through aviation regulations, safety
standards, and operational efficiency. # standards, and operational efficiency.
url: # url:
link_text: Website under construction # link_text: Website under construction
- icon: #- icon:
source: https://cloud.veen.world/s/logo_hunter_512x512/download # source: https://cloud.veen.world/s/logo_hunter_512x512/download
title: Wildlife Expert # title: Wildlife Expert
text: As a certified hunter and wildlife coach, I offer educational programs, nature # text: As a certified hunter and wildlife coach, I offer educational programs, nature
walks, survival trainings, and photo expeditions, merging ecological knowledge # walks, survival trainings, and photo expeditions, merging ecological knowledge
with nature respect. My goal is to foster sustainable conservation and enhance # with nature respect. My goal is to foster sustainable conservation and enhance
appreciation for the natural world through responsible practices. # appreciation for the natural world through responsible practices.
url: # url:
link_text: Website under construction # link_text: Website under construction
- icon: #- icon:
source: https://cloud.veen.world/s/logo_diver_512x512/download # source: https://cloud.veen.world/s/logo_diver_512x512/download
title: Master Diver # title: Master Diver
text: As a certified master diver with trainings in various specialties, I offer # text: As a certified master diver with trainings in various specialties, I offer
diving instruction, underwater photography, and guided dive tours. My experience # diving instruction, underwater photography, and guided dive tours. My experience
ensures safe and enriching underwater adventures, highlighting marine conservation # ensures safe and enriching underwater adventures, highlighting marine conservation
and the wonders of aquatic ecosystems. # and the wonders of aquatic ecosystems.
url: # url:
link_text: Website under construction # link_text: Website under construction
- icon: #- icon:
source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download # source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
title: Massage Therapist # title: Massage Therapist
text: Certified in Tantra Massage, I offer unique full-body rituals to awaken senses # text: Certified in Tantra Massage, I offer unique full-body rituals to awaken senses
and harmonize body and mind. My sessions, a blend of ancient Tantra and modern # and harmonize body and mind. My sessions, a blend of ancient Tantra and modern
relaxation, focus on energy flow, personal growth, and spiritual awakening. # relaxation, focus on energy flow, personal growth, and spiritual awakening.
url: # url:
link_text: Website under construction # link_text: Website under construction
company: platform:
titel: Kevin Veen-Birkenbach titel: Kevin Veen-Birkenbach
subtitel: Consulting and Coaching Solutions subtitel: Consulting and Coaching Solutions
logo: logo:
source: https://cloud.veen.world/s/logo_face_512x512/download source: https://cloud.veen.world/s/logo_face_512x512/download
favicon: favicon:
source: https://cloud.veen.world/s/veen_world_favicon/download source: https://cloud.veen.world/s/veen_world_favicon/download
company:
titel: Kevin Veen-Birkenbach
subtitel: Consulting and Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_cymais_512x512/download
address: address:
street: Afrikanische Straße 43 street: Afrikanische Straße 43
postal_code: DE-13351 postal_code: DE-13351
city: Berlin city: Berlin
country: Germany country: Germany
imprint_url: https://s.veen.world/imprint imprint: https://veen.world/
navigation: navigation:
header: header:
children: children:
- link: accounts.channels.children - link: accounts.publishingchannels.children
- link: accounts.socialnetworks
- name: Contact - name: Contact
description: Get in touch description: Get in touch
icon: icon:
@@ -429,6 +466,12 @@ navigation:
class: bi bi-clipboard2-check-fill class: bi bi-clipboard2-check-fill
url: https://kanban.veen.world/ url: https://kanban.veen.world/
- name: Snipe IT
description: Manage my inventory
icon:
class: fas fa-box-open
url: https://inventory.veen.world/
- name: Communication - name: Communication
icon: icon:
class: fa-solid fa-comments class: fa-solid fa-comments
@@ -450,16 +493,34 @@ navigation:
icon: icon:
class: fa-solid fa-envelope class: fa-solid fa-envelope
url: https://mail.veen.world/ url: https://mail.veen.world/
- name: Tools - name: Administration
icon: icon:
class: fas fa-tools class: fas fa-building
children: children:
- name: Matomo - name: Matomo
description: Analyze with Matomo description: Analyze with Matomo
icon: icon:
class: fa-solid fa-chart-simple class: fa-solid fa-chart-simple
url: https://matomo.veen.world/ url: https://matomo.veen.world/
- name: phpMyAdmin
description: Administrate MySQL and MariaDB databases
icon:
class: fas fa-database
url: https://phpmyadmin.veen.world/
- name: Keycloak
description: Manage User via Keycloak
icon:
class: fas fa-user-shield
url: https://auth.veen.world/admin
- name: LDAP
description: Manage LDAP
icon:
class: fas fa-key
url: https://ldap.veen.world/
- name: Tools
icon:
class: fas fa-tools
children:
- name: Baserow - name: Baserow
description: Organize with Baserow description: Organize with Baserow
icon: icon:
@@ -476,7 +537,6 @@ navigation:
icon: icon:
class: fa-solid fa-cloud class: fa-solid fa-cloud
url: https://cloud.veen.world/ url: https://cloud.veen.world/
- name: About Me - name: About Me
description: All information about me description: All information about me
icon: icon:
@@ -557,9 +617,67 @@ navigation:
class: fa-solid fa-layer-group class: fa-solid fa-layer-group
url: https://s.veen.world/skillmatrix url: https://s.veen.world/skillmatrix
- link: accounts - link: accounts
- name: Support Me
description: "Discover all the ways you can support my work."
icon:
class: fa-solid fa-hands-helping
children:
- name: Buy me a Coffee
description: "Support my work with a coffee every cup helps!"
icon:
class: fa-solid fa-mug-hot
url: https://s.veen.world/buymeacoffee
- name: Patreon
description: "Become a member and support me monthly with exclusive content."
icon:
class: fa-brands fa-patreon
url: https://s.veen.world/patreon
- name: PayPal
description: "Donate to my open source projects with a one-time or monthly PayPal contribution."
icon:
class: fa-brands fa-paypal
url: https://s.veen.world/paypaldonate
- name: GitHub Sponsors
description: "Directly support my projects through GitHub Sponsors."
icon:
class: fa-brands fa-github
url: https://s.veen.world/githubsponsors
- name: Imprint - name: Imprint
description: Check out the imprint information description: Check out the imprint information
icon: icon:
class: fa-solid fa-scale-balanced class: fa-solid fa-scale-balanced
url: https://s.veen.world/imprint url: https://s.veen.world/imprint
iframe: true
- name: Settings
description: Application settings
icon:
class: fa-solid fa-cog
children:
- name: Toggle Fullscreen
description: Enter or exit fullscreen mode
icon:
class: fa-solid fa-expand-arrows-alt
onclick: "toggleFullscreen()"
- name: Toggle Full Width
description: Switch between normal and full-width layout
icon:
class: fa-solid fa-arrows-left-right
onclick: "setFullWidth(!initFullWidthFromUrl())"
- name: Open in new tab
description: Open the currently embedded iframe URL in a fresh browser tab
icon:
class: fa-solid fa-up-right-from-square
onclick: openIframeInNewTab()
- name: Print
description: Print the current view
icon:
class: fa-solid fa-print
onclick: window.print()
- name: Zoom +
icon:
class: fa-solid fa-search-plus
onclick: zoomPage(1.1)
- name: Zoom
icon:
class: fa-solid fa-search-minus
onclick: zoomPage(0.9)

19
app/cypress.config.js Normal file
View File

@@ -0,0 +1,19 @@
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
// your app under test must already be running on this port
baseUrl: `http://localhost:${process.env.PORT || 5001}`,
defaultCommandTimeout: 60000,
pageLoadTimeout: 60000,
requestTimeout: 1500,
responseTimeout: 15000,
specPattern: 'cypress/e2e/**/*.spec.js',
supportFile: false,
setupNodeEvents(on, config) {
// here you could hook into events, but we dont need anything special
return config;
}
},
});

View File

@@ -0,0 +1,90 @@
// cypress/e2e/container.spec.js
describe('Custom Scroll & Container Resizing', () => {
beforeEach(() => {
// Assumes your app is running at baseUrl, and container.js is loaded on “/”
cy.visit('/');
});
it('on load, the scroll-container gets a positive height and proper overflow', () => {
// wait for our JS to run
cy.window().should('have.property', 'adjustScrollContainerHeight');
// Grab the inline style of .scroll-container
cy.get('.scroll-container')
.should('have.attr', 'style')
.then(style => {
// height:<number>px must be present
const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
expect(m, 'height set').to.not.be.null;
expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
// overflow shorthand should include both hidden & auto (order-insensitive)
expect(style).to.include('overflow:');
expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
});
});
it('on window resize, scroll-container height updates', () => {
// record original height
cy.get('.scroll-container')
.invoke('css', 'height')
.then(orig => {
// resize to a smaller viewport
cy.viewport(320, 480);
cy.wait(100); // allow resize handler to fire
cy.get('.scroll-container')
.invoke('css', 'height')
.then(newH => {
expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
});
});
});
context('custom scrollbar thumb', () => {
beforeEach(() => {
// inject tall content to force scrolling
cy.get('.scroll-container').then($sc => {
$sc[0].innerHTML = '<div style="height:2000px">long</div>';
});
// re-run scrollbar setup
cy.window().invoke('updateCustomScrollbar');
});
it('shows a thumb with reasonable size & position', () => {
cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
cy.get('#scroll-thumb')
.should('have.css', 'height')
.then(h => {
const hh = parseFloat(h);
expect(hh).to.be.at.least(20);
// ensure thumb is smaller than container
cy.get('#custom-scrollbar')
.invoke('css', 'height')
.then(ch => {
expect(hh).to.be.lessThan(parseFloat(ch));
});
});
// scroll a bit and verify thumb.top changes
cy.get('.scroll-container').scrollTo(0, 200);
cy.wait(50);
cy.get('#scroll-thumb')
.invoke('css', 'top')
.then(t => {
expect(parseFloat(t)).to.be.greaterThan(0);
});
});
it('hides scrollbar when content fits', () => {
// remove overflow
cy.get('.scroll-container').then($sc => {
$sc[0].innerHTML = '<div style="height:10px">tiny</div>';
});
cy.window().invoke('updateCustomScrollbar');
cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
});
});
});

View File

@@ -0,0 +1,85 @@
// cypress/e2e/fullscreen.spec.js
describe('Fullscreen Toggle', () => {
const ROOT = '/';
beforeEach(() => {
cy.visit(ROOT);
});
it('defaults to normal mode when no fullscreen param is present', () => {
// Body should not have fullscreen class
cy.get('body').should('not.have.class', 'fullscreen');
// URL should not include `fullscreen`
cy.url().should('not.include', 'fullscreen=');
// Header and footer should be visible (max-height > 0)
cy.get('header').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
cy.get('footer').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
});
it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
cy.visit(`${ROOT}?fullscreen=1`);
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
// Header and footer should be collapsed (max-height == 0)
cy.get('header').should('have.css', 'max-height', '0px');
cy.get('footer').should('have.css', 'max-height', '0px');
});
it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
cy.window().then(win => {
win.exitFullscreen(); // ensure starting state
win.enterFullscreen();
});
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid');
cy.get('header').should('have.css', 'max-height', '0px');
cy.get('footer').should('have.css', 'max-height', '0px');
});
it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
// start in fullscreen
cy.window().invoke('enterFullscreen');
// then exit
cy.window().invoke('exitFullscreen');
cy.get('body').should('not.have.class', 'fullscreen');
cy.url().should('not.include', 'fullscreen=');
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
// Header and footer should be expanded again
cy.get('header').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
cy.get('footer').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
});
it('toggleFullscreen() toggles into and out of fullscreen', () => {
// Toggle into fullscreen
cy.window().invoke('toggleFullscreen');
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
// Toggle back
cy.window().invoke('toggleFullscreen');
cy.get('body').should('not.have.class', 'fullscreen');
cy.url().should('not.include', 'fullscreen=');
});
});

View File

@@ -0,0 +1,61 @@
// cypress/e2e/fullwidth.spec.js
describe('Full-width Toggle', () => {
// test page must include your <div class="container"> wrapper
const ROOT = '/';
it('defaults to .container when no param is present', () => {
cy.visit(ROOT);
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
// URL should not include `fullwidth`
cy.url().should('not.include', 'fullwidth=');
});
it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
cy.visit(`${ROOT}?fullwidth=1`);
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid')
.and('not.have.class', 'container');
cy.url().should('include', 'fullwidth=1');
});
it('setFullWidth(true) switches to container-fluid and updates URL', () => {
cy.visit(ROOT);
// call your global function
cy.window().invoke('setFullWidth', true);
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid')
.and('not.have.class', 'container');
cy.url().should('include', 'fullwidth=1');
});
it('setFullWidth(false) reverts to container and removes URL param', () => {
cy.visit(`${ROOT}?fullwidth=1`);
// now reset
cy.window().invoke('setFullWidth', false);
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
cy.url().should('not.include', 'fullwidth=1');
});
it('updateUrlFullWidth() toggles the query param without changing layout', () => {
cy.visit(ROOT);
// manually toggle URL only
cy.window().invoke('updateUrlFullWidth', true);
cy.url().should('include', 'fullwidth=1');
cy.window().invoke('updateUrlFullWidth', false);
cy.url().should('not.include', 'fullwidth=');
});
});

View File

@@ -0,0 +1,46 @@
// cypress/e2e/iframe.spec.js
describe('Iframe integration', () => {
beforeEach(() => {
// Visit the apps base URL (configured in cypress.config.js)
cy.visit('/');
});
it('opens the iframe when an .iframe-link is clicked', () => {
// Find the first iframe-link on the page
cy.get('.iframe-link').first().then($link => {
const href = $link.prop('href');
// Click it
cy.wrap($link).click();
// The URL should now include ?iframe=<encoded href>
cy.url().should('include', 'iframe=' + encodeURIComponent(href));
// The <body> should have the "fullscreen" class
cy.get('body').should('have.class', 'fullscreen');
// And the <main> should contain a visible <iframe src="<href>">
cy.get('main iframe')
.should('have.attr', 'src', href)
.and('be.visible');
});
});
it('restores the original content when a .js-restore element is clicked', () => {
// First open the iframe
cy.get('.iframe-link').first().click();
// Then click the first .js-restore element (e.g. header or logo)
cy.get('.js-restore').first().click();
// The URL must no longer include the iframe parameter
cy.url().should('not.include', 'iframe=');
// The <body> should no longer have the "fullscreen" class
cy.get('body').should('not.have.class', 'fullscreen');
// And no <iframe> should remain inside <main>
cy.get('main iframe').should('not.exist');
});
});

View File

@@ -0,0 +1,130 @@
// cypress/e2e/dynamic_popup.spec.js
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

View File

@@ -0,0 +1,130 @@
// cypress/e2e/dynamic_popup.spec.js
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

View File

@@ -0,0 +1,32 @@
describe('Navbar Logo Visibility', () => {
beforeEach(() => {
cy.visit('/');
});
it('should have #navbar_logo present in the DOM', () => {
cy.get('#navbar_logo').should('exist');
});
it('should be invisible (opacity 0) by default', () => {
cy.get('#navbar_logo')
.should('exist')
.and('have.css', 'opacity', '0');
});
it('should become visible (opacity 1) after entering fullscreen', () => {
cy.window().then(win => {
win.enterFullscreen();
});
cy.get('#navbar_logo', { timeout: 4000 })
.should('have.css', 'opacity', '1');
});
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
cy.window().then(win => {
win.enterFullscreen();
win.exitFullscreen();
});
cy.get('#navbar_logo', { timeout: 4000 })
.should('have.css', 'opacity', '0');
});
});

View File

@@ -0,0 +1,130 @@
// cypress/e2e/dynamic_popup.spec.js
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

16
app/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"bootstrap": "5.2.2",
"bootstrap-icons": "1.9.1",
"jquery": "3.6.0",
"marked": "^4.3.0"
},
"devDependencies": {
"cypress": "^14.5.1"
},
"scripts": {
"build": "node scripts/copy-vendor.js",
"postinstall": "node scripts/copy-vendor.js"
}
}

View File

@@ -1,3 +0,0 @@
flask
requests
pyyaml

View File

@@ -0,0 +1,71 @@
'use strict';
/**
* Copies third-party browser assets from node_modules into static/vendor/
* so Flask can serve them without any CDN dependency.
* Runs automatically via the "postinstall" npm hook.
*/
const fs = require('fs');
const path = require('path');
const NM = path.join(__dirname, '..', 'node_modules');
const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
function copyFile(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
}
}
// Bootstrap CSS + JS bundle
copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
);
// Bootstrap Icons CSS + embedded fonts
copyFile(
path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
);
copyDir(
path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
);
// Font Awesome Free CSS + webfonts
copyFile(
path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
);
copyDir(
path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
path.join(VENDOR, 'fontawesome', 'webfonts')
);
// marked browser UMD build (path varies by version)
const markedCandidates = [
path.join(NM, 'marked', 'marked.min.js'), // v4.x
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
];
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
// jQuery
copyFile(
path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
path.join(VENDOR, 'jquery', 'jquery.min.js')
);

View File

@@ -0,0 +1,31 @@
/* Set the scroll container to only scroll vertically */
.scroll-container {
overflow-y: auto;
overflow-x: hidden;
/* Hide native scrollbar */
scrollbar-width: none; /* Firefox */
}
.scroll-container::-webkit-scrollbar {
display: none; /* WebKit */
}
#custom-scrollbar {
position: fixed;
top: 0;
right: 0;
width: 8px;
/* height: 100vh; <-- remove or adjust this line */
background: transparent;
transition: opacity 0.3s ease;
opacity: 1;
}
/* The scrollbar thumb */
#scroll-thumb {
position: absolute;
top: 0;
width: 100%;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}

View File

@@ -38,6 +38,18 @@ a {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
.card {
transition: background-color 1s ease, transform 1s ease;
transition: color 1s ease, transform 1s ease;
}
.card:hover {
/* invert everything inside the card */
filter: invert(0.8) hue-rotate(144deg);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.card-body { .card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -70,7 +82,6 @@ h3.card-title {
/* Footer styles */ /* Footer styles */
.footer { .footer {
margin-top: 12px;
text-align: center; text-align: center;
font-size: 0.7em; font-size: 0.7em;
} }
@@ -84,3 +95,121 @@ h3.card-title {
h3.footer-title { h3.footer-title {
font-size: 1.3em; font-size: 1.3em;
} }
.card-img-top i, .card-img-top svg{
font-size: 100px;
fill: currentColor;
width: 100px;
height: auto;
}
div#navbarNavheader li.nav-item {
margin-left: 6px;
}
div#navbarNavfooter li.nav-item {
margin-right: 6px;
}
/* Prevent nav items from wrapping to a second line */
div#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
}
div#navbarNavheader .navbar-nav::-webkit-scrollbar,
div#navbarNavfooter .navbar-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
main, footer, header, nav {
position: relative;
box-shadow:
/* Inner shadow */
inset 10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Left inner shadow */
inset -10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right inner shadow */
/* Outer shadow */
10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right outer shadow */
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
overflow: visible;
}
header{
padding: 12px;
}
header,
footer {
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: 0;
z-index: 1030;
background-color: var(--bs-light);
}
/* at the end of default.css */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
iframe{
margin-bottom: -10px;
}
.container-fluid {
max-width: 100% !important;
}
:root {
--anim-duration: 3s; /* Basis-Dauer */
}
.container,
.container-fluid {
transition:
max-width var(--anim-duration) ease-in-out,
padding-left var(--anim-duration) ease-in-out,
padding-right var(--anim-duration) ease-in-out;
}
#navbar_logo {
opacity: 0;
max-width: 0;
overflow: hidden;
transition: opacity var(--anim-duration) ease-in-out,
max-width var(--anim-duration) ease-in-out;
}
#navbar_logo.visible {
opacity: 1 !important;
max-width: 300px;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */
max-height: 200px;
padding: 1rem;
transition:
max-height var(--anim-duration) ease-in-out,
padding var(--anim-duration) ease-in-out;
}
/* 2. In fullscreen mode, collapse them */
body.fullscreen header,
body.fullscreen footer {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}

View File

@@ -1,20 +1,24 @@
/* Top-Level Dropdown-Menü */ /* Top-level dropdown menu */
.nav-item .dropdown-menu { .nav-item .dropdown-menu {
position: absolute; /* Wichtig für Positionierung */ position: absolute; /* Important for positioning */
top: 100%; /* Standardmäßige Öffnung nach unten */ top: 100%; /* Default opening direction: downwards */
left: 0; left: 0;
z-index: 1050; /* Damit das Menü über anderen Elementen liegt */ z-index: 1050; /* Ensures the menu appears above other elements */
} }
/* Submenu-Position */ /* Submenu position */
.dropdown-submenu > .dropdown-menu { .dropdown-submenu > .dropdown-menu {
position: absolute; position: absolute;
top: 0; top: 0;
left: 100%; /* Öffnen nach rechts */ left: 100%; /* Opens to the right */
z-index: 1050; z-index: 1050;
} }
/* Sicherstellen, dass der Übergang smooth ist */ /* Ensure a smooth transition */
.dropdown-menu { .dropdown-menu {
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
} }
nav.navbar {
border-radius: 0;
}

108
app/static/js/container.js Normal file
View File

@@ -0,0 +1,108 @@
function adjustScrollContainerHeight() {
const mainEl = document.getElementById('main');
const scrollContainer = mainEl.querySelector('.scroll-container');
const scrollbarContainer = document.getElementById('custom-scrollbar');
const container = mainEl.parentElement;
let siblingsHeight = 0;
Array.from(container.children).forEach(child => {
if(child !== mainEl && child !== scrollbarContainer) {
const style = window.getComputedStyle(child);
const height = child.offsetHeight;
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
siblingsHeight += height + marginTop + marginBottom;
}
});
// Calculate the available height for the scroll area
const availableHeight = window.innerHeight - siblingsHeight;
scrollContainer.style.height = availableHeight + 'px';
scrollContainer.style.overflowY = 'auto';
scrollContainer.style.overflowX = 'hidden';
// Get the current position and height of the scroll container
const scrollContainerRect = scrollContainer.getBoundingClientRect();
// Set the position (top) and height of the custom scrollbar track
scrollbarContainer.style.top = scrollContainerRect.top + 'px';
scrollbarContainer.style.height = scrollContainerRect.height + 'px';
}
window.addEventListener('load', adjustScrollContainerHeight);
window.addEventListener('resize', adjustScrollContainerHeight);
// 2. Updates the thumb (size and position) of the custom scrollbar
function updateCustomScrollbar() {
const scrollContainer = document.querySelector('.scroll-container');
const thumb = document.getElementById('scroll-thumb');
const customScrollbar = document.getElementById('custom-scrollbar');
if (!scrollContainer || !thumb || !customScrollbar) return;
const contentHeight = scrollContainer.scrollHeight;
const containerHeight = scrollContainer.clientHeight;
const scrollTop = scrollContainer.scrollTop;
// Calculate the thumb height (minimum 20px)
let thumbHeight = (containerHeight / contentHeight) * containerHeight;
thumbHeight = Math.max(thumbHeight, 20);
thumb.style.height = thumbHeight + 'px';
// Calculate the thumb position
const maxScrollTop = contentHeight - containerHeight;
const maxThumbTop = containerHeight - thumbHeight;
const thumbTop = maxScrollTop ? (scrollTop / maxScrollTop) * maxThumbTop : 0;
thumb.style.top = thumbTop + 'px';
// Show the scrollbar if content overflows, otherwise hide it
customScrollbar.style.opacity = contentHeight > containerHeight ? '1' : '0';
}
// Update the thumb when the container is scrolled
const scrollContainer = document.querySelector('.scroll-container');
if (scrollContainer) {
scrollContainer.addEventListener('scroll', updateCustomScrollbar);
}
window.addEventListener('resize', updateCustomScrollbar);
window.addEventListener('load', updateCustomScrollbar);
// 3. Interactivity: Enable drag & drop for the scroll thumb
let isDragging = false;
let dragStartY = 0;
let scrollStartY = 0;
const thumb = document.getElementById('scroll-thumb');
if (thumb) {
thumb.addEventListener('mousedown', function(e) {
isDragging = true;
dragStartY = e.clientY;
scrollStartY = scrollContainer.scrollTop;
e.preventDefault();
});
}
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const containerHeight = scrollContainer.clientHeight;
const contentHeight = scrollContainer.scrollHeight;
const thumbHeight = thumb.offsetHeight;
const maxScrollTop = contentHeight - containerHeight;
const maxThumbTop = containerHeight - thumbHeight;
const deltaY = e.clientY - dragStartY;
// Calculate the new thumb top position
let newThumbTop = (scrollStartY / maxScrollTop) * maxThumbTop + deltaY;
newThumbTop = Math.max(0, Math.min(newThumbTop, maxThumbTop));
// Calculate the new scroll position based on the thumb position
const newScrollTop = (newThumbTop / maxThumbTop) * maxScrollTop;
scrollContainer.scrollTop = newScrollTop;
});
document.addEventListener('mouseup', function(e) {
if (isDragging) {
isDragging = false;
}
});

110
app/static/js/fullscreen.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* Add or remove the `fullscreen=1` URL parameter.
* @param {boolean} enabled
*/
function updateUrlFullscreen(enabled) {
var url = new URL(window.location);
if (enabled) url.searchParams.set('fullscreen', '1');
else url.searchParams.delete('fullscreen');
window.history.replaceState({}, '', url);
}
/**
* Starts a requestAnimationFrame loop that calls your recalc methods,
* and stops automatically when the headers max-height transition ends.
*/
function recalcWhileCollapsing() {
const header = document.querySelector('header');
if (!header) return;
// 1) Start the RAF loop
let rafId;
const step = () => {
adjustScrollContainerHeight();
updateCustomScrollbar();
rafId = requestAnimationFrame(step);
};
step();
// 2) Listen for the end of the max-height transition
function onEnd(e) {
if (e.propertyName === 'max-height') {
cancelAnimationFrame(rafId);
header.removeEventListener('transitionend', onEnd);
}
}
header.addEventListener('transitionend', onEnd);
}
function enterFullscreen() {
document.body.classList.add('fullscreen');
setFullWidth(true);
updateUrlFullscreen(true);
// Nur jetzt sichtbar machen
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.classList.add('visible');
}
recalcWhileCollapsing();
}
function exitFullscreen() {
document.body.classList.remove('fullscreen');
setFullWidth(false);
updateUrlFullscreen(false);
// Jetzt wieder verstecken
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.classList.remove('visible');
}
recalcWhileCollapsing();
}
/**
* Toggle between enter and exit fullscreen.
*/
function toggleFullscreen() {
const params = new URLSearchParams(window.location.search);
const isFull = params.get('fullscreen') === '1';
if (isFull) exitFullscreen();
else enterFullscreen();
}
/**
* Read `fullscreen` flag from URL on load.
* @returns {boolean}
*/
function initFullscreenFromUrl() {
return new URLSearchParams(window.location.search).get('fullscreen') === '1';
}
// On page load: apply fullwidth & fullscreen flags
window.addEventListener('DOMContentLoaded', function() {
// first fullwidth
var wasFullWidth = initFullWidthFromUrl();
setFullWidth(wasFullWidth);
// now fullscreen
if (initFullscreenFromUrl()) {
enterFullscreen();
}
});
// Mirror native F11/fullscreen API events
document.addEventListener('fullscreenchange', function() {
if (document.fullscreenElement) enterFullscreen();
else exitFullscreen();
});
window.addEventListener('resize', function() {
var isUiFs = Math.abs(window.innerHeight - screen.height) < 2;
if (isUiFs) enterFullscreen();
else exitFullscreen();
});
// Expose globally
window.fullscreen = enterFullscreen;
window.exitFullscreen = exitFullscreen;
window.toggleFullscreen = toggleFullscreen;

View File

@@ -0,0 +1,42 @@
/**
* Toggles the .container class between .container and .container-fluid.
* @param {boolean} enabled true = full width, false = normal.
*/
function setFullWidth(enabled) {
var el = document.querySelector('.container, .container-fluid');
if (!el) return;
if (enabled) {
el.classList.replace('container', 'container-fluid');
updateUrlFullWidth(true);
} else {
el.classList.replace('container-fluid', 'container');
updateUrlFullWidth(false);
}
}
/**
* Reads the URL parameter `fullwidth` and applies full width if it's set.
* @returns {boolean} current fullwidth state
*/
function initFullWidthFromUrl() {
var isFull = new URLSearchParams(window.location.search).get('fullwidth') === '1';
setFullWidth(isFull);
return isFull;
}
/**
* Adds or removes the `fullwidth=1` URL parameter.
* @param {boolean} enabled
*/
function updateUrlFullWidth(enabled) {
var url = new URL(window.location);
if (enabled) url.searchParams.set('fullwidth', '1');
else url.searchParams.delete('fullwidth');
window.history.replaceState({}, '', url);
}
// Expose globally
window.setFullWidth = setFullWidth;
window.initFullWidthFromUrl = initFullWidthFromUrl;
window.updateUrlFullWidth = updateUrlFullWidth;

189
app/static/js/iframe.js Normal file
View File

@@ -0,0 +1,189 @@
// Global variables to store elements and original state
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
let currentIframeUrl = null;
// === Auto-open iframe if URL parameter is present ===
window.addEventListener('DOMContentLoaded', () => {
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
if (paramUrl) {
currentIframeUrl = paramUrl;
enterFullscreen();
openIframe(paramUrl);
}
});
// Synchronize the height of the iframe to match the scroll-container or main element
function syncIframeHeight() {
const iframe = mainElement.querySelector("iframe");
if (iframe) {
if (scrollbarContainer) {
// Prefer inline height, otherwise inline max-height
const inlineHeight = scrollbarContainer.style.height;
const inlineMax = scrollbarContainer.style.maxHeight;
const target = inlineHeight || inlineMax;
if (target) {
iframe.style.height = target;
} else {
iframe.style.height = mainElement.style.height;
}
} else {
iframe.style.height = mainElement.style.height;
}
}
}
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
function openIframe(url) {
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
var $customScroll = customScrollbar ? $(customScrollbar) : null;
var $main = $(mainElement);
// Original-Content ausblenden mit 1500 ms
var promises = [];
if ($container) promises.push($container.fadeOut(1500).promise());
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
$.when.apply($, promises).done(function() {
// now that scroll areas are hidden, go fullscreen
enterFullscreen();
// create iframe if it doesnt exist yet
var $iframe = $main.find('iframe');
if ($iframe.length === 0) {
originalMainStyle = $main.attr('style') || null;
$iframe = $('<iframe>', {
width: '100%',
frameborder: 0,
scrolling: 'auto'
}).css({ overflow: 'auto', display: 'none' });
$main.append($iframe);
}
// Quelle setzen und mit 1500 ms einblenden
$iframe
.attr('src', url)
.fadeIn(1500, function() {
syncIframeHeight();
observeIframeNavigation();
});
// URL-State pushen
var newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', url);
window.history.pushState({ iframe: url }, '', newUrl);
});
}
/**
* Restore the original <main> content and exit fullscreen.
*/
function restoreOriginal() {
// Exit fullscreen (collapse header/footer and run recalcs)
exitFullscreen();
// Replace <main> innerHTML with the snapshot we took on load
mainElement.innerHTML = originalContent;
// Reset any inline styles on mainElement
if (originalMainStyle !== null) {
mainElement.setAttribute('style', originalMainStyle);
} else {
mainElement.removeAttribute('style');
}
// Re-run height adjustments for scroll container & thumb
adjustScrollContainerHeight();
updateCustomScrollbar();
// Clear iframe state and URL param
currentIframeUrl = null;
history.replaceState(null, '', window.location.pathname);
}
// Initialize event listeners after DOM content is loaded
document.addEventListener("DOMContentLoaded", function() {
// Cache references to elements and original state
mainElement = document.querySelector("main");
originalContent = mainElement.innerHTML;
originalMainStyle = mainElement.getAttribute("style"); // may be null
container = document.querySelector(".container");
customScrollbar = document.getElementById("custom-scrollbar");
scrollbarContainer = container.querySelector(".scroll-container")
document.querySelectorAll(".js-restore").forEach(el => {
el.style.cursor = "pointer";
el.addEventListener("click", restoreOriginal);
});
// === Close iframe & exit fullscreen when any .js-restore is clicked ===
document.querySelectorAll('.js-restore').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
// first collapse header/footer and recalc container
exitFullscreen();
// then fade out and remove the iframe, fade content back
restoreOriginal();
// clear stored URL and reset browser address
currentIframeUrl = null;
history.replaceState(null, '', window.location.pathname);
});
});
});
/**
* Opens the current iframe URL in a new browser tab.
*/
function openIframeInNewTab() {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl) {
window.open(iframeUrl, '_blank');
} else {
alert('No iframe is currently open.');
}
}
// expose globally so your templates onclick can find it
window.openIframeInNewTab = openIframeInNewTab;
// Adjust iframe height on window resize
window.addEventListener('resize', syncIframeHeight);
/**
* Observe iframe location changes (Same-Origin only).
*/
function observeIframeNavigation() {
const iframe = mainElement.querySelector("iframe");
if (!iframe || !iframe.contentWindow) return;
let lastUrl = iframe.contentWindow.location.href;
setInterval(() => {
try {
const currentUrl = iframe.contentWindow.location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
const newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', currentUrl);
window.history.replaceState({}, '', newUrl);
}
} catch (e) {
// Cross-origin ignore
}
}, 500);
}
// Remember, open iframe, enter fullscreen, AND set the URL param immediately
document.querySelectorAll(".iframe-link").forEach(link => {
link.addEventListener("click", function(event) {
event.preventDefault();
currentIframeUrl = this.href;
enterFullscreen();
openIframe(currentIframeUrl);
// Update the browser URL right away
const newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', currentIframeUrl);
window.history.replaceState({ iframe: currentIframeUrl }, '', newUrl);
});
});

View File

@@ -1,8 +1,5 @@
function openDynamicPopup(subitem) { function openDynamicPopup(subitem) {
// Schließe alle offenen Modals
closeAllModals(); closeAllModals();
// Setze den Titel mit Icon, falls vorhanden
const modalTitle = document.getElementById('dynamicModalLabel'); const modalTitle = document.getElementById('dynamicModalLabel');
if (subitem.icon && subitem.icon.class) { if (subitem.icon && subitem.icon.class) {
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`; modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
@@ -10,7 +7,6 @@ function openDynamicPopup(subitem) {
modalTitle.innerText = subitem.name; modalTitle.innerText = subitem.name;
} }
// Setze den Identifier, falls vorhanden
const identifierBox = document.getElementById('dynamicIdentifierBox'); const identifierBox = document.getElementById('dynamicIdentifierBox');
const modalContent = document.getElementById('dynamicModalContent'); const modalContent = document.getElementById('dynamicModalContent');
if (subitem.identifier) { if (subitem.identifier) {
@@ -21,25 +17,19 @@ function openDynamicPopup(subitem) {
modalContent.value = ''; modalContent.value = '';
} }
// Konfiguriere die Warnbox mit Markdown function toggleBox(boxId, textId, content) {
const warningBox = document.getElementById('dynamicModalWarning'); const box = document.getElementById(boxId);
if (subitem.warning) { if (content) {
warningBox.classList.remove('d-none'); box.classList.remove('d-none');
document.getElementById('dynamicModalWarningText').innerHTML = marked.parse(subitem.warning); document.getElementById(textId).innerHTML = marked.parse(content);
} else { } else {
warningBox.classList.add('d-none'); box.classList.add('d-none');
}
} }
// Konfiguriere die Infobox mit Markdown toggleBox('dynamicModalWarning', 'dynamicModalWarningText', subitem.warning);
const infoBox = document.getElementById('dynamicModalInfo'); toggleBox('dynamicModalInfo', 'dynamicModalInfoText', subitem.info);
if (subitem.info) {
infoBox.classList.remove('d-none');
document.getElementById('dynamicModalInfoText').innerHTML = marked.parse(subitem.info);
} else {
infoBox.classList.add('d-none');
}
// Zeige die Beschreibung, falls keine URL vorhanden ist
const descriptionText = document.getElementById('dynamicDescriptionText'); const descriptionText = document.getElementById('dynamicDescriptionText');
if (!subitem.url && subitem.description) { if (!subitem.url && subitem.description) {
descriptionText.classList.remove('d-none'); descriptionText.classList.remove('d-none');
@@ -49,41 +39,53 @@ function openDynamicPopup(subitem) {
descriptionText.innerText = ''; descriptionText.innerText = '';
} }
// Konfiguriere den Link oder die Beschreibung
const linkBox = document.getElementById('dynamicModalLink'); const linkBox = document.getElementById('dynamicModalLink');
const linkHref = document.getElementById('dynamicModalLinkHref'); const linkHref = document.getElementById('dynamicModalLinkHref');
if (subitem.url) { if (subitem.url) {
linkBox.classList.remove('d-none'); linkBox.classList.remove('d-none');
linkHref.href = subitem.url; linkHref.href = subitem.url;
linkHref.innerText = subitem.description || "Open Link"; linkHref.innerText = subitem.description || "Open Link";
if (subitem.iframe) {
linkHref.classList.add('iframe');
// Attach an event listener that prevents the default behavior and
// opens the URL in an iframe when clicked.
linkHref.addEventListener('click', function(event) {
event.preventDefault();
openIframe(subitem.url);
closeAllModals()
});
}
} else { } else {
linkBox.classList.add('d-none'); linkBox.classList.add('d-none');
linkHref.href = '#'; linkHref.href = '#';
} }
function populateSection(sectionId, listId, items, onClickHandler) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
list.innerHTML = '';
// Konfiguriere die Alternativen if (items && items.length > 0) {
const alternativesSection = document.getElementById('dynamicAlternativesSection'); section.classList.remove('d-none');
const alternativesList = document.getElementById('dynamicAlternativesList'); items.forEach(item => {
alternativesList.innerHTML = ''; // Clear existing alternatives
if (subitem.alternatives && subitem.alternatives.length > 0) {
alternativesSection.classList.remove('d-none');
subitem.alternatives.forEach(alt => {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center'); listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
listItem.innerHTML = ` listItem.innerHTML = `
<span> <span>
<i class="${alt.icon.class}"></i> ${alt.name} <i class="${item.icon.class}"></i> ${item.name}
</span> </span>
<button class="btn btn-outline-secondary btn-sm">Open</button> <button class="btn btn-outline-secondary btn-sm">Open</button>
`; `;
listItem.querySelector('button').addEventListener('click', () => openDynamicPopup(alt)); listItem.querySelector('button').addEventListener('click', () => onClickHandler(item));
alternativesList.appendChild(listItem); list.appendChild(listItem);
}); });
} else { } else {
alternativesSection.classList.add('d-none'); section.classList.add('d-none');
}
} }
// Kopierfunktion für den Identifier populateSection('dynamicAlternativesSection', 'dynamicAlternativesList', subitem.alternatives, openDynamicPopup);
populateSection('dynamicChildrenSection', 'dynamicChildrenList', subitem.children, openDynamicPopup);
const copyButton = document.getElementById('dynamicCopyButton'); const copyButton = document.getElementById('dynamicCopyButton');
copyButton.onclick = () => { copyButton.onclick = () => {
modalContent.select(); modalContent.select();
@@ -92,25 +94,20 @@ function openDynamicPopup(subitem) {
}); });
}; };
// Modal anzeigen
const modal = new bootstrap.Modal(document.getElementById('dynamicModal')); const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
modal.show(); modal.show();
} }
function closeAllModals() { function closeAllModals() {
const modals = document.querySelectorAll('.modal.show'); // Alle offenen Modals finden const modals = document.querySelectorAll('.modal.show');
modals.forEach(modal => { modals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal); const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) { if (modalInstance) {
modalInstance.hide(); // Modal ausblenden modalInstance.hide();
} }
}); });
// Entferne die "modal-backdrop"-Elemente
const backdrops = document.querySelectorAll('.modal-backdrop'); const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => backdrop.remove()); backdrops.forEach(backdrop => backdrop.remove());
// Entferne die Klasse, die den Hintergrund ausgraut
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.style.paddingRight = ''; document.body.style.paddingRight = '';

View File

@@ -17,15 +17,15 @@ document.addEventListener('DOMContentLoaded', () => {
}, 500); }, 500);
} }
// Öffnen beim Hovern // Open on hover
item.addEventListener('mouseenter', onMouseEnter); item.addEventListener('mouseenter', onMouseEnter);
// Verzögertes Schließen beim Verlassen // Delayed close on mouse leave
item.addEventListener('mouseleave', onMouseLeave); item.addEventListener('mouseleave', onMouseLeave);
// Öffnen und Position anpassen beim Klicken // Open and adjust position on click
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.stopPropagation(); // Verhindert das Schließen von Menüs bei Klick e.stopPropagation(); // Prevents menus from closing when clicking inside
if (item.classList.contains('open')) { if (item.classList.contains('open')) {
closeMenu(item); closeMenu(item);
} else { } else {
@@ -44,7 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
addAllMenuEventListeners(); addAllMenuEventListeners();
// Globale Klick-Listener, um Menüs zu schließen, wenn außerhalb geklickt wird // Global click listener to close menus when clicking outside
document.addEventListener('click', () => { document.addEventListener('click', () => {
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item)); [...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
}); });
@@ -71,7 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function isSmallScreen() { function isSmallScreen() {
return window.innerWidth < 992; // Bootstrap-Breakpoint für 'lg' return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
} }
function adjustMenuPosition(submenu, parent, isTopLevel) { function adjustMenuPosition(submenu, parent, isTopLevel) {
@@ -89,12 +89,12 @@ function adjustMenuPosition(submenu, parent, isTopLevel) {
submenu.style.right = ''; submenu.style.right = '';
if (isTopLevel) { if (isTopLevel) {
if (isSmallScreen && spaceBelow < spaceAbove) { if (isSmallScreen() && spaceBelow < spaceAbove) {
// Für kleine Bildschirme: Menü direkt über dem Eltern-Element öffnen // For small screens: Open menu directly above the parent element
submenu.style.top = 'auto'; submenu.style.top = 'auto';
submenu.style.bottom = `${parentRect.height}px`; // Direkt über dem Eltern-Element submenu.style.bottom = `${parentRect.height}px`; // Directly above the parent element
} }
// Top-Level-Menü // Top-level menu
else if (spaceBelow < spaceAbove) { else if (spaceBelow < spaceAbove) {
submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`; submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`;
submenu.style.top = 'auto'; submenu.style.top = 'auto';
@@ -103,18 +103,18 @@ function adjustMenuPosition(submenu, parent, isTopLevel) {
submenu.style.bottom = 'auto'; submenu.style.bottom = 'auto';
} }
} else { } else {
// Submenü // Submenu
const prefersRight = spaceRight >= spaceLeft; const prefersRight = spaceRight >= spaceLeft;
submenu.style.left = prefersRight ? '100%' : 'auto'; submenu.style.left = prefersRight ? '100%' : 'auto';
submenu.style.right = prefersRight ? 'auto' : '100%'; submenu.style.right = prefersRight ? 'auto' : '100%';
// Nach oben öffnen, wenn unten kein Platz ist // Open upwards if there's no space below
if (spaceBelow < spaceAbove) { if (spaceBelow < spaceAbove) {
submenu.style.bottom = `0`; submenu.style.bottom = `0`;
submenu.style.top = `auto`; submenu.style.top = `auto`;
} else { } else {
submenu.style.top = `0`; submenu.style.top = `0`;
submenu.style.bottom = '${parentRect.height}px'; submenu.style.bottom = `${parentRect.height}px`;
} }
} }
} }

View File

@@ -1,4 +1,4 @@
// Initialisiert alle Tooltips auf der Seite // Initializes all tooltips on the page
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) { tooltipTriggerList.forEach(function (tooltipTriggerEl) {

View File

@@ -1,32 +1,58 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{company.titel}}</title> <title>{{platform.titel}}</title>
<meta charset="utf-8" > <meta charset="utf-8" >
<link rel="icon" type="image/x-icon" href="{{company.favicon.cache}}"> <link
rel="icon"
type="image/x-icon"
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
>
<!-- Bootstrap CSS only --> <!-- Bootstrap CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous"> <link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap JavaScript Bundle with Popper --> <!-- Bootstrap JavaScript Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script> <script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.css') }}">
<!-- Fontawesome --> <!-- Fontawesome -->
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}">
<!-- Markdown --> <!-- Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
<link rel="stylesheet" href="static/css/default.css"> <link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
<!-- JQuery -->
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
</head> </head>
<body> <body
{% if apod_bg %}
style="
background-image: url('{{ apod_bg }}');
background-size: cover;
background-position: center;
background-attachment: fixed;
"
{% endif %}
>
<div class="container"> <div class="container">
<header class="header"> <header class="header js-restore">
<img src="{{company.logo.cache}}" alt="logo"/> <img
<h1>{{company.titel}}</h1> src="{{ url_for('static', filename=platform.logo.cache) }}"
<h2>{{company.subtitel}}</h2> alt="logo"
<br /> />
<h1>{{platform.titel}}</h1>
<h2>{{platform.subtitel}}</h2>
</header> </header>
{% set menu_type = "header" %} {% set menu_type = "header" %}
{% include "moduls/navigation.html.j2"%} {% include "moduls/navigation.html.j2"%}
<main id="main">
<div class="scroll-container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
</main>
<!-- Custom scrollbar element fixiert am rechten Rand -->
<div id="custom-scrollbar">
<div id="scroll-thumb"></div>
</div>
{% set menu_type = "footer" %} {% set menu_type = "footer" %}
{% include "moduls/navigation.html.j2" %} {% include "moduls/navigation.html.j2" %}
<footer class="footer"> <footer class="footer">
@@ -34,14 +60,22 @@
<p itemprop="name">{{ company.titel }} <br /> <p itemprop="name">{{ company.titel }} <br />
{{ company.subtitel }}</p> {{ company.subtitel }}</p>
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span> <span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
<p><a href="{{company.imprint_url}}"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p> <p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
</div> </div>
</footer> </footer>
</div> </div>
<!-- Include modal --> <!-- Include modal -->
{% include "moduls/modal.html.j2" %} {% include "moduls/modal.html.j2" %}
<script src="{{ url_for('static', filename='js/modal.js') }}"></script> {% for name in [
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script> 'modal',
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script> 'navigation',
'tooltip',
'container',
'fullwidth',
'fullscreen',
'iframe',
] %}
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
{% endfor %}
</body> </body>
</html> </html>

View File

@@ -1,14 +1,31 @@
<div class="card-column col-lg-3 col-md-6 col-12"> <div class="card-column {{ lg_class }} {{ md_class }} col-12">
<div class="card h-100 d-flex flex-column"> <div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<div class="card-img-top"> <div class="card-img-top">
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;"> {% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }}
{% elif card.icon.cache %}
<img
src="{{ url_for('static', filename=card.icon.cache) }}"
alt="{{ card.title }}"
style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
{% if card.icon.class %}
<i class="{{ card.icon.class }}" style="display:none;"></i>
{% endif %}
{% elif card.icon.class %}
<i class="{{ card.icon.class }}"></i>
{% endif %}
</div> </div>
<hr /> <hr />
<h3 class="card-title">{{ card.title }}</h3> <h3 class="card-title">{{ card.title }}</h3>
<p class="card-text">{{ card.text }}</p> <p class="card-text">{{ card.text }}</p>
{% if card.url %} {% if card.url %}
<a href="{{ card.url }}" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> {{ card.link_text }}</a> <a
href="{{ card.url }}"
class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
</a>
{% else %} {% else %}
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }} <i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
{% endif %} {% endif %}

View File

@@ -1,3 +1,16 @@
{% macro alert_box(id, alert_class, icon_class, title, text_id) %}
<div id="{{ id }}" class="alert {{ alert_class }} d-none" role="alert">
<h5><i class="{{ icon_class }}"></i> {{ title }} </h5><span id="{{ text_id }}"></span>
</div>
{% endmacro %}
{% macro list_section(id, title, list_id) %}
<div id="{{ id }}" class="mt-4 d-none">
<h6>{{ title }}:</h6>
<ul class="list-group" id="{{ list_id }}"></ul>
</div>
{% endmacro %}
<div class="modal fade" id="dynamicModal" tabindex="-1" aria-labelledby="dynamicModalLabel" aria-hidden="true"> <div class="modal fade" id="dynamicModal" tabindex="-1" aria-labelledby="dynamicModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@@ -6,30 +19,31 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Warnbox mit Markdown --> <!-- Warning box with Markdown -->
<div id="dynamicModalWarning" class="alert alert-warning d-none" role="alert"> {{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', 'Warning', 'dynamicModalWarningText') }}
<h5><i class="fa-solid fa-triangle-exclamation"></i> Warning </h5><span id="dynamicModalWarningText"></span>
</div> <!-- Info box with Markdown -->
<!-- Infobox mit Markdown --> {{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', 'Information', 'dynamicModalInfoText') }}
<div id="dynamicModalInfo" class="alert alert-info d-none" role="alert">
<h5><i class="fa-solid fa-circle-info"></i> Information</h5><span id="dynamicModalInfoText"></span>
</div>
<!-- Description text --> <!-- Description text -->
<div id="dynamicDescriptionText" class="mt-2 d-none"></div> <div id="dynamicDescriptionText" class="mt-2 d-none"></div>
<!-- Eingabebox für Identifier -->
<!-- Input box for Identifier -->
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none"> <div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
<input type="text" id="dynamicModalContent" class="form-control" readonly> <input type="text" id="dynamicModalContent" class="form-control" readonly>
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">Copy</button> <button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">Copy</button>
</div> </div>
<!-- Link --> <!-- Link -->
<div id="dynamicModalLink" class="mt-3 d-none"> <div id="dynamicModalLink" class="mt-3 d-none">
<a href="#" target="_blank" class="btn btn-primary w-100" id="dynamicModalLinkHref"></a> <a href="#" target="_blank" class="btn btn-primary w-100" id="dynamicModalLinkHref"></a>
</div> </div>
<!-- Alternativen -->
<div id="dynamicAlternativesSection" class="mt-4 d-none"> <!-- Options -->
<h6>Alternatives:</h6> {{ list_section('dynamicChildrenSection', 'Options', 'dynamicChildrenList') }}
<ul class="list-group" id="dynamicAlternativesList"></ul>
</div> <!-- Alternatives -->
{{ list_section('dynamicAlternativesSection', 'Alternatives', 'dynamicAlternativesList') }}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">Close</button>

View File

@@ -8,26 +8,39 @@
{% endmacro %} {% endmacro %}
<!-- Template for children --> <!-- Template for children -->
{% macro render_children(children) %} {% macro render_children(children) %}
{% for children in children %} {% for child in children %}
{% if children.children %} {% if child.children %}
<li class="dropdown-submenu position-relative"> <li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}"> <a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
{{ render_icon_and_name(children) }} {{ render_icon_and_name(child) }}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{{ render_children(children.children) }} {{ render_children(child.children) }}
</ul> </ul>
</li> </li>
{% elif children.identifier or children.warning or children.info %}
{% elif child.identifier or child.warning or child.info %}
<li> <li>
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}"> <a class="dropdown-item"
{{ render_icon_and_name(children) }} onclick='openDynamicPopup({{ child|tojson|safe }})'
data-bs-toggle="tooltip"
title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a> </a>
</li> </li>
{% else %} {% else %}
<li> <li>
<a class="dropdown-item" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}"> <a class="dropdown-item {% if child.iframe %}iframe-link{% endif %}"
{{ render_icon_and_name(children) }} {% if child.onclick %}
onclick="{{ child.onclick }}"
{% else %}
href="{{ child.url }}"
{% endif %}
target="{{ child.target|default('_blank') }}"
data-bs-toggle="tooltip"
title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -35,18 +48,37 @@
{% endmacro %} {% endmacro %}
<!-- Navigation Bar --> <!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light"> <nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}"> <div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
{% if menu_type == "header" %}
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
<img
src="{{ url_for('static', filename=platform.logo.cache) }}"
alt="{{ platform.titel }}"
class="d-inline-block align-text-top"
style="height:2rem">
<div class="ms-2 d-flex flex-column">
<span class="fs-4 fw-bold mb-0">{{ platform.titel }}</span>
{# <small class="fs-7 text-muted">{{ platform.subtitel }}</small> #}
</div>
{% endif %}
</a>
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group"> <ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
{% for item in navigation[menu_type].children %} {% for item in navigation[menu_type].children %}
{% if item.url %} {% if item.url or item.onclick %}
<!-- Single Item -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link btn btn-light" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}"> <a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}"
{% if item.onclick %}
onclick="{{ item.onclick }}"
{% else %}
href="{{ item.url }}"
{% endif %}
target="{{ item.target|default('_blank') }}"
data-bs-toggle="tooltip"
title="{{ item.description }}">
{{ render_icon_and_name(item) }} {{ render_icon_and_name(item) }}
</a> </a>
</li> </li>
@@ -68,5 +100,4 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div>
</nav> </nav>

View File

@@ -3,6 +3,9 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
{% for card in cards %} {% for card in cards %}
{% set index = loop.index0 %}
{% set lg_class = lg_classes[index] %}
{% set md_class = md_classes[index] %}
{% include "moduls/card.html.j2" %} {% include "moduls/card.html.j2" %}
{% endfor %} {% endfor %}
</div> </div>

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utilities used by the Portfolio UI web application."""

View File

@@ -1,66 +1,47 @@
import os
import hashlib import hashlib
import mimetypes
import os
import requests import requests
class CacheManager: class CacheManager:
"""
A class to manage caching of files, including creating temporary directories
and caching files locally with hashed filenames.
"""
def __init__(self, cache_dir="static/cache"): def __init__(self, cache_dir="static/cache"):
"""
Initialize the CacheManager with a cache directory.
:param cache_dir: The directory where cached files will be stored.
"""
self.cache_dir = cache_dir self.cache_dir = cache_dir
self._ensure_cache_dir_exists() self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self): def _ensure_cache_dir_exists(self):
""" os.makedirs(self.cache_dir, exist_ok=True)
Ensure the cache directory exists. If it doesn't, create it.
"""
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
def clear_cache(self): def clear_cache(self):
"""
Clear all files in the cache directory.
"""
if os.path.exists(self.cache_dir): if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir): for filename in os.listdir(self.cache_dir):
file_path = os.path.join(self.cache_dir, filename) path = os.path.join(self.cache_dir, filename)
if os.path.isfile(file_path): if os.path.isfile(path):
os.remove(file_path) os.remove(path)
def cache_file(self, file_url): def cache_file(self, file_url):
""" hash_suffix = hashlib.blake2s(
Download a file and store it locally in the cache directory with a hashed filename. file_url.encode("utf-8"),
digest_size=8,
).hexdigest()
parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1]
:param file_url: The URL of the file to cache. try:
:return: The local path of the cached file. resp = requests.get(file_url, stream=True, timeout=5)
""" resp.raise_for_status()
# Generate a hashed filename based on the URL except requests.RequestException:
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8) return None
hash_suffix = hash_object.hexdigest()
# Determine the base name for the file content_type = resp.headers.get("Content-Type", "")
splitted_file_url = file_url.split("/") ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1] filename = f"{base}_{hash_suffix}{ext}"
# Construct the full path for the cached file
filename = f"{base_name}_{hash_suffix}.png"
full_path = os.path.join(self.cache_dir, filename) full_path = os.path.join(self.cache_dir, filename)
# If the file already exists, return the cached path if not os.path.exists(full_path):
if os.path.exists(full_path): with open(full_path, "wb") as f:
return full_path for chunk in resp.iter_content(1024):
f.write(chunk)
# Download the file and save it locally return f"cache/{filename}"
response = requests.get(file_url, stream=True)
if response.status_code == 200:
with open(full_path, "wb") as file:
for chunk in response.iter_content(1024):
file.write(chunk)
return full_path

View File

@@ -0,0 +1,42 @@
def compute_card_classes(cards):
num_cards = len(cards)
lg_classes = []
if num_cards < 3:
if num_cards == 2:
lg_classes = ["col-lg-6", "col-lg-6"]
else:
lg_classes = ["col-lg-12"]
elif num_cards % 4 == 0:
lg_classes = ["col-lg-3"] * num_cards
elif num_cards % 3 == 0:
lg_classes = ["col-lg-4"] * num_cards
elif num_cards % 2 == 0:
lg_classes = ["col-lg-6"] * num_cards
else:
# For complex cases (e.g., 5, 7, 11) Ensure at least 3 per row
for i in range(num_cards):
if num_cards % 4 == 3:
if i < 3:
lg_classes.append("col-lg-4")
else:
lg_classes.append("col-lg-3")
elif num_cards % 4 == 1:
if i < 2:
lg_classes.append("col-lg-6")
elif i < 5:
lg_classes.append("col-lg-4")
else:
lg_classes.append("col-lg-3")
elif num_cards % 3 == 2:
if i < 2:
lg_classes.append("col-lg-6")
else:
lg_classes.append("col-lg-4")
# Use a full-width last card on medium screens only when the total count is odd.
md_classes = []
for i in range(num_cards):
if num_cards % 2 == 0 or i < num_cards - 1:
md_classes.append("col-md-6")
else:
md_classes.append("col-md-12")
return lg_classes, md_classes

View File

@@ -1,4 +1,3 @@
from pprint import pprint
class ConfigurationResolver: class ConfigurationResolver:
""" """
A class to resolve `link` entries in a nested configuration structure. A class to resolve `link` entries in a nested configuration structure.
@@ -14,19 +13,6 @@ class ConfigurationResolver:
""" """
self._recursive_resolve(self.config, self.config) self._recursive_resolve(self.config, self.config)
def __load_children(self,path):
"""
Check if explicitly children should be loaded and not parent
"""
return path.split('.').pop() == "children"
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
if old_key in dict_origine:
# Entferne den alten Key
old_value = dict_origine.pop(old_key)
# Füge die neuen Key-Value-Paare hinzu
dict_origine.update(new_dict)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements): def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
index = list_origine.index(old_element) index = list_origine.index(old_element)
list_origine[index : index + 1] = new_elements list_origine[index : index + 1] = new_elements
@@ -43,10 +29,17 @@ class ConfigurationResolver:
for key, value in list(current_config.items()): for key, value in list(current_config.items()):
if key == "children": if key == "children":
if value is None or not isinstance(value, list): if value is None or not isinstance(value, list):
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.") raise ValueError(
"Expected 'children' to be a list, but got "
f"{type(value).__name__} instead."
)
for item in value: for item in value:
if "link" in item: if "link" in item:
loaded_link = self._find_entry(root_config, item['link'].lower(), False) loaded_link = self._find_entry(
root_config,
self._mapped_key(item["link"]),
False,
)
if isinstance(loaded_link, list): if isinstance(loaded_link, list):
self._replace_in_list_by_list(value, item, loaded_link) self._replace_in_list_by_list(value, item, loaded_link)
else: else:
@@ -55,15 +48,24 @@ class ConfigurationResolver:
self._recursive_resolve(value, root_config) self._recursive_resolve(value, root_config)
elif key == "link": elif key == "link":
try: try:
loaded = self._find_entry(root_config, value.lower(), True) loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
if isinstance(loaded, list) and len(loaded) > 2: if isinstance(loaded, list) and len(loaded) > 2:
loaded = self._find_entry(root_config, value.lower(), False) loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
current_config.clear() current_config.clear()
current_config.update(loaded) current_config.update(loaded)
except Exception as e: except Exception as e:
raise ValueError( raise ValueError(
f"Error resolving link '{value}': {str(e)}. " f"Error resolving link '{value}': {str(e)}. "
f"Current path: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "") f"Current part: {key}, Current config: {current_config}"
+ (
f", Loaded: {loaded}"
if "loaded" in locals() or "loaded" in globals()
else ""
)
) )
else: else:
self._recursive_resolve(value, root_config) self._recursive_resolve(value, root_config)
@@ -72,14 +74,24 @@ class ConfigurationResolver:
self._recursive_resolve(item, root_config) self._recursive_resolve(item, root_config)
def _get_children(self, current): def _get_children(self, current):
if isinstance(current, dict) and ("children" in current and current["children"]): if isinstance(current, dict) and (
"children" in current and current["children"]
):
current = current["children"] current = current["children"]
return current return current
def _mapped_key(self, name):
return name.replace(" ", "").lower()
def _find_by_name(self, current, part): def _find_by_name(self, current, part):
return next( return next(
(item for item in current if isinstance(item, dict) and item.get("name", "").lower() == part), (
None item
for item in current
if isinstance(item, dict)
and self._mapped_key(item.get("name", "")) == part
),
None,
) )
def _find_entry(self, config, path, children): def _find_entry(self, config, path, children):
@@ -87,38 +99,44 @@ class ConfigurationResolver:
Finds an entry in the configuration by a dot-separated path. Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation. Supports both dictionaries and lists with `children` navigation.
""" """
parts = path.split('.') parts = path.split(".")
current = config current = config
for part in parts: for part in parts:
if isinstance(current, list): if isinstance(current, list):
# If children explicit declared just load children
if part != "children": if part != "children":
# Look for a matching name in the list
found = self._find_by_name(current, part) found = self._find_by_name(current, part)
if found: if found:
current = found current = found
print( print(
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. " f"Matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}" f"Current list: {current}"
) )
else: else:
raise ValueError( raise ValueError(
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. " f"No matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}" f"Current list: {current}"
) )
elif isinstance(current, dict): elif isinstance(current, dict):
# Case-insensitive dictionary lookup key = next((k for k in current if self._mapped_key(k) == part), None)
key = next((k for k in current if k.lower() == part), None)
if key is None: if key is None:
if "children" not in current:
raise KeyError(
"No 'children' found in current dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
current = self._find_by_name(current["children"], part) current = self._find_by_name(current["children"], part)
if not current: if not current:
raise KeyError( raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. " f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}" f"Current dictionary: {current}"
) )
else: else:
current = current[key] current = current[key]
else: else:
raise ValueError( raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. " f"Invalid path segment '{part}'. Current type: {type(current)}. "

View File

@@ -1,12 +1,15 @@
version: '3.8'
services: services:
landingpage: portfolio:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: application-landingpage container_name: portfolio
container_name: landingpage
ports: ports:
- "5000:5000" - "${PORT:-5000}:${PORT:-5000}"
env_file:
- .env
volumes: volumes:
- ./app:/app - ./app:/app
restart: unless-stopped restart: unless-stopped

2
env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=5001
FLASK_ENV=production

79
main.py Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
Automatically generates CLI commands based on the Makefile definitions.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
def load_targets(makefile_path):
"""
Parse the Makefile to extract targets and their help comments.
Assumes each target is defined as 'name:' and the following line that starts
with '\t#' provides its help text.
"""
targets = []
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
with open(makefile_path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
for idx, line in enumerate(lines):
m = pattern.match(line)
if m:
name = m.group(1)
help_text = ""
if idx + 1 < len(lines):
next_line = lines[idx + 1].lstrip()
if next_line.startswith("#"):
help_text = next_line.lstrip("# ").strip()
targets.append((name, help_text))
return targets
def run_command(command, dry_run=False):
"""Utility to run shell commands."""
print(f"Executing: {' '.join(command)}")
if dry_run:
print("Dry run enabled: command not executed.")
return
try:
subprocess.check_call(command)
except subprocess.CalledProcessError as e:
print(f"Error: Command failed with exit code {e.returncode}")
sys.exit(e.returncode)
def main():
parser = argparse.ArgumentParser(
description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the generated Make command without executing it.",
)
subparsers = parser.add_subparsers(
title="Available commands",
dest="command",
required=True,
)
targets = load_targets(MAKEFILE_PATH)
for name, help_text in targets:
sp = subparsers.add_parser(name, help=help_text)
sp.set_defaults(target=name)
args = parser.parse_args()
cmd = ["make", args.target]
run_command(cmd, dry_run=args.dry_run)
if __name__ == "__main__":
main()

44
pyproject.toml Normal file
View File

@@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "1.1.0"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask",
"pyyaml",
"requests",
]
[project.optional-dependencies]
dev = [
"bandit",
"pip-audit",
"ruff",
]
[tool.setuptools]
py-modules = ["main"]
[tool.setuptools.packages.find]
include = ["app", "app.*"]
[tool.setuptools.package-data]
app = [
"config.sample.yaml",
"templates/**/*.j2",
"static/css/*.css",
"static/js/*.js",
]
[tool.ruff]
target-version = "py312"
line-length = 88
extend-exclude = ["app/static/cache", "build"]
[tool.ruff.lint]
select = ["E", "F", "I"]

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,54 @@
import tomllib
import unittest
from pathlib import Path
class TestPythonPackaging(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.pyproject_path = self.repo_root / "pyproject.toml"
with self.pyproject_path.open("rb") as handle:
self.pyproject = tomllib.load(handle)
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
build_system = self.pyproject["build-system"]
project = self.pyproject["project"]
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
self.assertIn("setuptools>=69", build_system["requires"])
self.assertGreaterEqual(
set(project["dependencies"]),
{"flask", "pyyaml", "requests"},
)
self.assertEqual(project["requires-python"], ">=3.12")
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
project = self.pyproject["project"]
setuptools_config = self.pyproject["tool"]["setuptools"]
package_find = setuptools_config["packages"]["find"]
package_data = setuptools_config["package-data"]["app"]
self.assertGreaterEqual(
set(project["optional-dependencies"]["dev"]),
{"bandit", "pip-audit", "ruff"},
)
self.assertEqual(setuptools_config["py-modules"], ["main"])
self.assertEqual(package_find["include"], ["app", "app.*"])
self.assertIn("config.sample.yaml", package_data)
self.assertIn("templates/**/*.j2", package_data)
self.assertIn("static/css/*.css", package_data)
self.assertIn("static/js/*.js", package_data)
def test_legacy_requirements_files_are_removed(self):
self.assertFalse((self.repo_root / "requirements.txt").exists())
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
def test_package_init_files_exist(self):
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
import yaml
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
SKIP_FILES = {"app/config.yaml"}
YAML_SUFFIXES = {".yml", ".yaml"}
class TestYamlSyntax(unittest.TestCase):
def test_all_repository_yaml_files_are_valid(self):
repo_root = Path(__file__).resolve().parents[2]
invalid_files = []
for path in repo_root.rglob("*"):
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
continue
relative_path = path.relative_to(repo_root).as_posix()
if relative_path in SKIP_FILES:
continue
if any(part in SKIP_DIR_NAMES for part in path.parts):
continue
try:
with path.open("r", encoding="utf-8") as handle:
yaml.safe_load(handle)
except yaml.YAMLError as error:
invalid_files.append((relative_path, str(error).splitlines()[0]))
except Exception as error:
invalid_files.append((relative_path, f"Unexpected error: {error}"))
self.assertFalse(
invalid_files,
"Found invalid YAML files:\n"
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
)
if __name__ == "__main__":
unittest.main()

1
tests/lint/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
import ast
import unittest
from pathlib import Path
class TestTestFilesContainUnittestTests(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.tests_dir = self.repo_root / "tests"
self.assertTrue(
self.tests_dir.is_dir(),
f"'tests' directory not found at: {self.tests_dir}",
)
def _iter_test_files(self) -> list[Path]:
return sorted(self.tests_dir.rglob("test_*.py"))
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError as error:
raise AssertionError(f"SyntaxError in {path}: {error}") from error
testcase_aliases = {"TestCase"}
unittest_aliases = {"unittest"}
for node in tree.body:
if isinstance(node, ast.Import):
for import_name in node.names:
if import_name.name == "unittest":
unittest_aliases.add(import_name.asname or "unittest")
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
for import_name in node.names:
if import_name.name == "TestCase":
testcase_aliases.add(import_name.asname or "TestCase")
def is_testcase_base(base: ast.expr) -> bool:
if isinstance(base, ast.Name) and base.id in testcase_aliases:
return True
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
return (
isinstance(base.value, ast.Name)
and base.value.id in unittest_aliases
)
return False
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
node.name.startswith("test_")
):
return True
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
if not any(is_testcase_base(base) for base in node.bases):
continue
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
item.name.startswith("test_")
):
return True
return False
def test_all_test_py_files_contain_runnable_tests(self) -> None:
test_files = self._iter_test_files()
self.assertTrue(test_files, "No test_*.py files found under tests/")
offenders = []
for path in test_files:
if not self._file_contains_runnable_unittest_test(path):
offenders.append(path.relative_to(self.repo_root).as_posix())
self.assertFalse(
offenders,
"These test_*.py files do not define any unittest-runnable tests:\n"
+ "\n".join(f"- {path}" for path in offenders),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
import unittest
from pathlib import Path
class TestTestFileNaming(unittest.TestCase):
def test_all_python_files_use_test_prefix(self):
tests_root = Path(__file__).resolve().parents[1]
invalid_files = []
for path in tests_root.rglob("*.py"):
if path.name == "__init__.py":
continue
if not path.name.startswith("test_"):
invalid_files.append(path.relative_to(tests_root).as_posix())
self.assertFalse(
invalid_files,
"The following Python files do not start with 'test_':\n"
+ "\n".join(f"- {path}" for path in invalid_files),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,57 @@
import subprocess
import unittest
from pathlib import Path
import yaml
class TestConfigHygiene(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.sample_config_path = self.repo_root / "app" / "config.sample.yaml"
def _is_tracked(self, path: str) -> bool:
result = subprocess.run(
["git", "ls-files", "--error-unmatch", path],
cwd=self.repo_root,
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
def _find_values_for_key(self, data, key_name: str):
if isinstance(data, dict):
for key, value in data.items():
if key == key_name:
yield value
yield from self._find_values_for_key(value, key_name)
elif isinstance(data, list):
for item in data:
yield from self._find_values_for_key(item, key_name)
def test_runtime_only_files_are_ignored_and_untracked(self):
gitignore_lines = (
(self.repo_root / ".gitignore").read_text(encoding="utf-8").splitlines()
)
self.assertIn("app/config.yaml", gitignore_lines)
self.assertIn(".env", gitignore_lines)
self.assertFalse(self._is_tracked("app/config.yaml"))
self.assertFalse(self._is_tracked(".env"))
def test_sample_config_keeps_the_nasa_api_key_placeholder(self):
with self.sample_config_path.open("r", encoding="utf-8") as handle:
sample_config = yaml.safe_load(handle)
nasa_api_keys = list(self._find_values_for_key(sample_config, "nasa_api_key"))
self.assertEqual(
nasa_api_keys,
["YOUR_REAL_KEY_HERE"],
"config.sample.yaml should only contain the documented NASA API key "
"placeholder.",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
import yaml
ALLOWED_URL_PREFIXES = ("https://", "mailto:", "tel:")
URL_KEYS = {"url", "imprint", "imprint_url"}
class TestSampleConfigUrls(unittest.TestCase):
def setUp(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
sample_config_path = repo_root / "app" / "config.sample.yaml"
with sample_config_path.open("r", encoding="utf-8") as handle:
self.sample_config = yaml.safe_load(handle)
def _iter_urls(self, data, path="root"):
if isinstance(data, dict):
for key, value in data.items():
next_path = f"{path}.{key}"
if key in URL_KEYS and isinstance(value, str):
yield next_path, value
yield from self._iter_urls(value, next_path)
elif isinstance(data, list):
for index, item in enumerate(data):
yield from self._iter_urls(item, f"{path}[{index}]")
def test_sample_config_urls_use_safe_schemes(self):
invalid_urls = [
f"{path} -> {url}"
for path, url in self._iter_urls(self.sample_config)
if not url.startswith(ALLOWED_URL_PREFIXES)
]
self.assertFalse(
invalid_urls,
"The sample config contains URLs with unsupported schemes:\n"
+ "\n".join(f"- {entry}" for entry in invalid_urls),
)
if __name__ == "__main__":
unittest.main()

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit test package for Portfolio UI."""

View File

@@ -0,0 +1,72 @@
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
import requests
from app.utils.cache_manager import CacheManager
class TestCacheManager(unittest.TestCase):
def test_init_creates_cache_directory(self):
with TemporaryDirectory() as temp_dir:
cache_dir = Path(temp_dir) / "cache"
self.assertFalse(cache_dir.exists())
CacheManager(str(cache_dir))
self.assertTrue(cache_dir.is_dir())
def test_clear_cache_removes_files_but_keeps_subdirectories(self):
with TemporaryDirectory() as temp_dir:
cache_dir = Path(temp_dir) / "cache"
nested_dir = cache_dir / "nested"
nested_dir.mkdir(parents=True)
file_path = cache_dir / "icon.png"
file_path.write_bytes(b"icon")
manager = CacheManager(str(cache_dir))
manager.clear_cache()
self.assertFalse(file_path.exists())
self.assertTrue(nested_dir.is_dir())
@patch("app.utils.cache_manager.requests.get")
def test_cache_file_downloads_and_stores_response(self, mock_get):
with TemporaryDirectory() as temp_dir:
manager = CacheManager(str(Path(temp_dir) / "cache"))
response = Mock()
response.headers = {"Content-Type": "image/svg+xml; charset=utf-8"}
response.iter_content.return_value = [b"<svg>ok</svg>"]
response.raise_for_status.return_value = None
mock_get.return_value = response
cached_path = manager.cache_file("https://example.com/logo/download")
self.assertIsNotNone(cached_path)
self.assertTrue(cached_path.startswith("cache/logo_"))
self.assertTrue(cached_path.endswith(".svg"))
stored_file = Path(manager.cache_dir) / Path(cached_path).name
self.assertEqual(stored_file.read_bytes(), b"<svg>ok</svg>")
mock_get.assert_called_once_with(
"https://example.com/logo/download",
stream=True,
timeout=5,
)
@patch("app.utils.cache_manager.requests.get")
def test_cache_file_returns_none_when_request_fails(self, mock_get):
with TemporaryDirectory() as temp_dir:
manager = CacheManager(str(Path(temp_dir) / "cache"))
mock_get.side_effect = requests.RequestException("network")
cached_path = manager.cache_file("https://example.com/icon.png")
self.assertIsNone(cached_path)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,49 @@
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from utils import check_hadolint_sarif
class TestCheckHadolintSarif(unittest.TestCase):
def test_main_returns_zero_for_clean_sarif(self):
sarif_payload = {
"runs": [
{
"results": [],
}
]
}
with TemporaryDirectory() as temp_dir:
sarif_path = Path(temp_dir) / "clean.sarif"
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
exit_code = check_hadolint_sarif.main([str(sarif_path)])
self.assertEqual(exit_code, 0)
def test_main_returns_one_for_warnings_or_errors(self):
sarif_payload = {
"runs": [
{
"results": [
{"level": "warning"},
{"level": "error"},
],
}
]
}
with TemporaryDirectory() as temp_dir:
sarif_path = Path(temp_dir) / "warnings.sarif"
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
exit_code = check_hadolint_sarif.main([str(sarif_path)])
self.assertEqual(exit_code, 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,39 @@
import unittest
from app.utils.compute_card_classes import compute_card_classes
class TestComputeCardClasses(unittest.TestCase):
def test_single_card_uses_full_width_classes(self):
lg_classes, md_classes = compute_card_classes([{"title": "One"}])
self.assertEqual(lg_classes, ["col-lg-12"])
self.assertEqual(md_classes, ["col-md-12"])
def test_two_cards_split_evenly(self):
lg_classes, md_classes = compute_card_classes([{}, {}])
self.assertEqual(lg_classes, ["col-lg-6", "col-lg-6"])
self.assertEqual(md_classes, ["col-md-6", "col-md-6"])
def test_three_cards_use_thirds(self):
lg_classes, md_classes = compute_card_classes([{}, {}, {}])
self.assertEqual(lg_classes, ["col-lg-4", "col-lg-4", "col-lg-4"])
self.assertEqual(md_classes, ["col-md-6", "col-md-6", "col-md-12"])
def test_five_cards_use_balanced_large_layout(self):
lg_classes, md_classes = compute_card_classes([{}, {}, {}, {}, {}])
self.assertEqual(
lg_classes,
["col-lg-6", "col-lg-6", "col-lg-4", "col-lg-4", "col-lg-4"],
)
self.assertEqual(
md_classes,
["col-md-6", "col-md-6", "col-md-6", "col-md-6", "col-md-12"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,74 @@
import unittest
from app.utils.configuration_resolver import ConfigurationResolver
class TestConfigurationResolver(unittest.TestCase):
def test_resolve_links_replaces_mapping_link_with_target_object(self):
config = {
"profiles": [
{"name": "Mastodon", "url": "https://example.com/@user"},
],
"featured": {"link": "profiles.mastodon"},
}
resolver = ConfigurationResolver(config)
resolver.resolve_links()
self.assertEqual(
resolver.get_config()["featured"],
{"name": "Mastodon", "url": "https://example.com/@user"},
)
def test_resolve_links_expands_children_link_to_list_entries(self):
config = {
"accounts": {
"children": [
{"name": "Matrix", "url": "https://matrix.example"},
{"name": "Signal", "url": "https://signal.example"},
]
},
"navigation": {
"children": [
{"link": "accounts.children"},
]
},
}
resolver = ConfigurationResolver(config)
resolver.resolve_links()
self.assertEqual(
resolver.get_config()["navigation"]["children"],
[
{"name": "Matrix", "url": "https://matrix.example"},
{"name": "Signal", "url": "https://signal.example"},
],
)
def test_resolve_links_rejects_non_list_children(self):
config = {"navigation": {"children": {"name": "Invalid"}}}
resolver = ConfigurationResolver(config)
with self.assertRaises(ValueError):
resolver.resolve_links()
def test_find_entry_handles_case_and_space_insensitive_paths(self):
config = {
"Social Networks": {
"children": [
{"name": "Friendica", "url": "https://friendica.example"},
]
}
}
resolver = ConfigurationResolver(config)
entry = resolver._find_entry(config, "socialnetworks.friendica", False)
self.assertEqual(entry["url"], "https://friendica.example")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,45 @@
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from utils import export_runtime_requirements
class TestExportRuntimeRequirements(unittest.TestCase):
def test_load_runtime_requirements_reads_project_dependencies(self):
pyproject_content = """
[project]
dependencies = [
"flask",
"requests>=2",
]
""".lstrip()
with TemporaryDirectory() as temp_dir:
pyproject_path = Path(temp_dir) / "pyproject.toml"
pyproject_path.write_text(pyproject_content, encoding="utf-8")
requirements = export_runtime_requirements.load_runtime_requirements(
pyproject_path
)
self.assertEqual(requirements, ["flask", "requests>=2"])
def test_main_prints_requirements_from_selected_pyproject(self):
pyproject_content = """
[project]
dependencies = [
"pyyaml",
]
""".lstrip()
with TemporaryDirectory() as temp_dir:
pyproject_path = Path(temp_dir) / "pyproject.toml"
pyproject_path.write_text(pyproject_content, encoding="utf-8")
with patch("builtins.print") as mock_print:
exit_code = export_runtime_requirements.main([str(pyproject_path)])
self.assertEqual(exit_code, 0)
mock_print.assert_called_once_with("pyyaml")

72
tests/unit/test_main.py Normal file
View File

@@ -0,0 +1,72 @@
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import main as portfolio_main
class TestMainCli(unittest.TestCase):
def test_load_targets_parses_help_comments(self):
makefile_content = """
.PHONY: foo bar
foo:
\t# Run foo
\t@echo foo
bar:
\t@echo bar
""".lstrip()
with TemporaryDirectory() as temp_dir:
makefile_path = Path(temp_dir) / "Makefile"
makefile_path.write_text(makefile_content, encoding="utf-8")
targets = portfolio_main.load_targets(makefile_path)
self.assertEqual(targets, [("foo", "Run foo"), ("bar", "")])
@patch("main.subprocess.check_call")
def test_run_command_executes_subprocess(self, mock_check_call):
portfolio_main.run_command(["make", "lint"])
mock_check_call.assert_called_once_with(["make", "lint"])
@patch("main.sys.exit", side_effect=SystemExit(7))
@patch(
"main.subprocess.check_call",
side_effect=subprocess.CalledProcessError(7, ["make", "lint"]),
)
def test_run_command_exits_with_subprocess_return_code(
self,
_mock_check_call,
mock_sys_exit,
):
with self.assertRaises(SystemExit) as context:
portfolio_main.run_command(["make", "lint"])
self.assertEqual(context.exception.code, 7)
mock_sys_exit.assert_called_once_with(7)
@patch("main.run_command")
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
def test_main_dispatches_selected_target(
self, _mock_load_targets, mock_run_command
):
with patch("sys.argv", ["main.py", "lint"]):
portfolio_main.main()
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=False)
@patch("main.run_command")
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
def test_main_passes_dry_run_flag(self, _mock_load_targets, mock_run_command):
with patch("sys.argv", ["main.py", "--dry-run", "lint"]):
portfolio_main.main()
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=True)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Fail when a hadolint SARIF report contains warnings or errors."""
from __future__ import annotations
import json
import sys
from pathlib import Path
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
sarif_path = Path(args[0] if args else "hadolint-results.sarif")
with sarif_path.open("r", encoding="utf-8") as handle:
sarif = json.load(handle)
results = sarif.get("runs", [{}])[0].get("results", [])
levels = [result.get("level", "") for result in results]
warnings = sum(1 for level in levels if level == "warning")
errors = sum(1 for level in levels if level == "error")
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
return 1 if warnings + errors > 0 else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Print runtime dependencies from pyproject.toml, one per line."""
import sys
import tomllib
from pathlib import Path
DEFAULT_PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml"
def load_runtime_requirements(
pyproject_path: Path = DEFAULT_PYPROJECT_PATH,
) -> list[str]:
with pyproject_path.open("rb") as handle:
pyproject = tomllib.load(handle)
return list(pyproject["project"]["dependencies"])
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
pyproject_path = Path(args[0]) if args else DEFAULT_PYPROJECT_PATH
for requirement in load_runtime_requirements(pyproject_path):
print(requirement)
return 0
if __name__ == "__main__":
raise SystemExit(main())