9 Commits

124 changed files with 935 additions and 8481 deletions

3
.claude/.gitignore vendored
View File

@@ -1,3 +0,0 @@
*
!.gitignore
!.settings.json

View File

@@ -1,52 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Bash(*)",
"Read(//tmp/**)",
"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)",
"Skill(update-config)",
"Skill(update-config:*)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(rm -rf*)",
"Bash(sudo*)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"additionalDirectories": [
"/tmp"
]
},
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"filesystem": {
"allowWrite": [
".",
"/tmp"
],
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.kube",
"~/.aws",
"~/.config/gcloud"
]
}
}
}

0
.codex
View File

7
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,90 +0,0 @@
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 }}

View File

@@ -1,132 +0,0 @@
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-yaml:
name: Lint YAML
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: Run yamllint
run: yamllint --strict .
lint-js:
name: Lint JavaScript
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- 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: Run eslint
working-directory: app
run: npx eslint .
lint-shell:
name: Lint shell scripts
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run shellcheck
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
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

View File

@@ -1,48 +0,0 @@
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 }}

View File

@@ -1,194 +0,0 @@
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

14
.gitignore vendored
View File

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

View File

@@ -1,23 +0,0 @@
---
# Correctness only. No `extends: default`, so nothing but the rules below runs:
# the repository predates this linter and its cosmetic findings (indentation,
# line length, trailing spaces) would be noise nobody acts on.
#
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
# identical keys without complaining, so a duplicated entry in a translation
# catalogue silently drops a translation.
ignore: |
.git/
.venv/
venv/
node_modules/
app/node_modules/
app/static/vendor/
build/
.ruff_cache/
rules:
key-duplicates: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -1,49 +0,0 @@
# Changelog
## [2.1.0] - 2026-08-22
* Multilingual site: every ISO 639-1 language has its own URL, */* follows the visitor's browser language, and a switcher in the navbar lists all 184 in their own script
* Right-to-left layouts: Arabic, Hebrew, Persian, Urdu and six more mirror the page automatically
* Machine translation: *make i18n* fills your card and menu texts from a LibreTranslate instance, *make i18n-ui* the interface strings; hand-corrected entries are never overwritten
* Brand protection: list your product names in *app/i18n/keep.txt* and they stay untranslated in every language
* Search engines: each page declares a canonical URL and an *hreflang* alternate per language
* Security: configuration and translations are HTML-escaped, script URLs are stripped from links, images and the iframe view — set *TRUSTED_HOSTS* in *.env* to pin your public hostname
* Reliability: a slow NASA APOD lookup no longer takes the page down
* Developer tooling: *make test-e2e* runs Cypress directly instead of through act, and YAML, JavaScript and shell now have linters in CI
## [2.0.0] - 2026-05-18
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
* README: PortUI screenshot added under the title
* Lint: removed an unused sys import in the live integration test
## [1.2.0] - 2026-05-11
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
## [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🥳

View File

@@ -1,5 +0,0 @@
# 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,20 +1,18 @@
FROM python:3.12-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 .
# Basis-Image für Python
FROM python:slim
# Arbeitsverzeichnis festlegen
WORKDIR /app
COPY app/ .
RUN npm install --prefix /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/ .
# Port freigeben
EXPOSE 5000
# Startbefehl
CMD ["python", "app.py"]

View File

@@ -1,4 +0,0 @@
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

256
Makefile
View File

@@ -1,256 +0,0 @@
# Load environment variables from .env
ifneq (,$(wildcard .env))
include .env
# Export variables defined in .env
export $(shell sed 's/=.*//' .env)
endif
PYTHON ?= python3
ACT ?= act
# Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
.PHONY: build
build: env config
# Build the Docker image.
@$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache
build-no-cache: env config
# Build the Docker image without cache.
@$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up
up: env config
# 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: env config config
# Run the container in development mode (hot-reload).
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-v "$(PWD)/app/:/app" \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
"$$IMAGE_NAME"
.PHONY: run-prod
run-prod: env config config
# Run the container in production mode.
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
"$$IMAGE_NAME"
.PHONY: logs
logs:
# Display the logs of the 'portfolio' container.
docker logs -f portfolio
.PHONY: dev
dev: env config
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod: env config
# 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: env
# Open the application in the browser at http://localhost:$$PORT
@$(call _require_env,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: i18n
i18n: env config
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: i18n-ui
i18n-ui: env
# Fill missing interface translations in app/i18n/ui/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--catalog ui \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.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: lint-yaml
lint-yaml: install-dev
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
$(PYTHON) -m yamllint --strict .
.PHONY: lint-js
lint-js: node-deps
# Lint the browser and Cypress JavaScript.
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
.PHONY: lint-shell
lint-shell:
# Lint the shell scripts.
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
.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-yaml lint-js lint-docker lint-shell 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: node-deps
node-deps:
# Install the Cypress binary and the browser vendor assets into app/.
cd app && npm install
.PHONY: test-e2e
test-e2e: env config node-deps
# Run Cypress against a locally started Flask app — no act, no runner image.
@$(call _require_env,PORT); \
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
.PHONY: test-e2e-act
test-e2e-act:
# Run the CI end-to-end job through 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.

232
README.md
View File

@@ -1,225 +1,37 @@
# PortUI 🖥️✨
# Landingpage
[![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)
## Access
### Locale
http://127.0.0.1:5000
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
![PortUI screenshot](assets/img/screenshot.png)
> 🚀 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.
- **184 Languages**
Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
- **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)
---
## 🏁 Getting Started
### 🔧 Prerequisites
- Docker & Docker Compose
- Basic Python & YAML knowledge
### 🛠️ Installation via Git
1. **Clone & enter repo**
```bash
git clone <repository_url>
cd <repository_directory>
```
2. **Configure**
Copy `config.sample.yaml` → `config.yaml` & customize.
3. **Build & run**
```bash
docker-compose up --build
```
4. **Browse**
Open [http://localhost:5000](http://localhost:5000)
### 📦 Installation via Kevins Package Manager
## Administrate Docker
### Stop and Destroy
```bash
docker stop landingpage
docker rm landingpage
```
### Build
```bash
pkgmgr install portui
docker build -t application-landingpage .
```
Once installed, the `portui` CLI is available system-wide.
---
## 🖥️ CLI Commands
### Run
#### Run Development Environment
```bash
portui --help
docker run -d -p 5000:5000 --name landingpage -v $(pwd)/app/:/app -e FLASK_APP=app.py -e FLASK_ENV=development application-landingpage
```
* `build`Build the Docker image
* `up`Start containers (with build)
* `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
```
* **`children`** enables multi-level menus.
* **`link`** references other YAML paths to avoid duplication.
---
## 🌍 Languages
Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display
name in its own script and a writing direction. The interface ships translated
for 29 of them; the rest fall back to English string by string until a
catalogue is filled. `/` serves the best match for the visitor's
`Accept-Language` header, `/<code>/` forces one, and a switcher in the navbar
lists them all. The ten right-to-left languages get `dir="rtl"` and Bootstrap's RTL
stylesheet automatically.
Translations live in two catalogues, both keyed by the English source string:
| Path | Tracked | Holds |
| --- | --- | --- |
| `app/i18n/ui/<code>.yaml` | yes | Interface strings. Shipped for 29 languages; English is the source and has no file. |
| `app/i18n/content/<code>.yaml` | no | Your `config.yaml` prose, generated per deployment. |
A string with no catalogue entry falls back to English, so a half-filled
catalogue degrades instead of breaking.
Fill the content catalogues from a [LibreTranslate](https://libretranslate.com/)
instance — set `LIBRETRANSLATE_URL` in `.env`, then:
#### Run Production Environment
```bash
make i18n
docker run -d -p 5000:5000 --name landingpage application-landingpage
```
This fills the interface strings of the languages that ship no catalogue as
well. Existing entries are never overwritten, and a string the shipped
catalogue already covers is never requested, so corrections you make by hand
survive later runs.
`name`, `title`, `description`, `text`, `warning`, `info` and `subtitel` are
translated; `url`, `link_text`, `identifier` and icon classes never are.
A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so
list the brands in `app/i18n/keep.txt`, one per line — they are then stored as
themselves in every language and cost no request:
### Debug
```bash
docker logs -f landingpage
```
# Strings utils/i18n_sync.py stores as themselves instead of translating.
Mastodon
Nextcloud
freelancermap.de
```
Add one-off entries with `--keep Foo Bar`, or point somewhere else with
`--keep-file`. A protected string never replaces an entry you already wrote by
hand.
---
## 🚢 Production Deployment
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
Because every page carries a canonical URL and 184 `hreflang` alternates, two
details of the proxy setup now matter:
* **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated.
Left empty, the app reflects whatever `Host` header arrives into its canonical,
`hreflang` and redirect URLs — so a shared cache in front of it can be made to
store a redirect pointing somewhere else.
* **Have the proxy send `X-Forwarded-Proto`.** Without it the app cannot know TLS
terminated upstream and every canonical URL claims `http://`. `X-Forwarded-Host`
is deliberately *not* trusted; set `Host` to the public name instead.
---
## 📜 License
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
---
## ✍️ Author
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
Enjoy building your portfolio! 🌟
## Author
This software was created from [Kevin Veen-Birkenbach](https://www.veen.world/) with the help of [ChatGPT]()

2
app/.gitignore vendored
View File

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

View File

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

View File

@@ -1,34 +1,10 @@
import logging
import os
from flask import Flask, render_template
import requests
import hashlib
import yaml
from flask import Flask, current_app, make_response, render_template, request, url_for
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
try:
from app.utils import i18n
from app.utils.asset_resolver import asset_src, resolve_asset_cache
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.asset_resolver import asset_src, resolve_asset_cache
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver
from utils import i18n
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
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}")
from utils.configuration_resolver import ConfigurationResolver
from utils.cache_manager import CacheManager
# Initialize the CacheManager
cache_manager = CacheManager()
@@ -36,163 +12,39 @@ cache_manager = CacheManager()
# Clear cache on startup
cache_manager.clear_cache()
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"]
"""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
resolver = ConfigurationResolver(config)
resolver.resolve_links()
# Update the app configuration
app.config.update(resolver.get_config())
app.config["TRANSLATED_CONFIG"] = {}
i18n.clear_catalogs()
def cache_icons_and_logos(app):
"""Resolve every icon/logo/favicon to either a local cache path or
an external URL (see ``resolve_asset_cache``)."""
for card in app.config["cards"]:
icon = card.get("icon")
if icon:
resolve_asset_cache(icon, cache_manager)
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
# Initialize Flask app
app = Flask(__name__)
app.jinja_options = {**app.jinja_options, "autoescape": True}
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1)
def trusted_hosts(raw):
"""Parse a comma-separated host list, or None when nothing is configured.
Args:
raw: the ``TRUSTED_HOSTS`` value, possibly empty.
"""
hosts = [host.strip() for host in raw.split(",") if host.strip()]
return hosts or None
app.config["TRUSTED_HOSTS"] = trusted_hosts(os.getenv("TRUSTED_HOSTS", ""))
# 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 ""
def template_asset_src(asset):
return asset_src(asset, lambda filename: url_for("static", filename=filename))
return dict(include_svg=include_svg, asset_src=template_asset_src)
# Hole die Umgebungsvariable FLASK_ENV oder setze einen Standardwert
FLASK_ENV = os.getenv("FLASK_ENV", "production")
@app.before_request
def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode."""
if FLASK_ENV == "development":
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.config["company"]["favicon"]["cache"] = cache_manager.cache_file(app.config["company"]["favicon"]["source"])
def translated_config(lang):
"""Return the configuration sections translated into ``lang``, memoized.
The memo is dropped by ``load_config``, so a development reload picks up
edited content on the next request.
"""
memo = app.config["TRANSLATED_CONFIG"]
if lang not in memo:
source = {section: app.config[section] for section in TRANSLATED_SECTIONS}
memo[lang] = i18n.translate_tree(source, lang)
return memo[lang]
def apod_background():
"""Return today's NASA APOD image URL, or None when unavailable."""
api_key = app.config.get("NASA_API_KEY")
if not api_key:
return None
try:
resp = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": api_key},
timeout=10,
)
except requests.RequestException:
logging.warning("APOD lookup failed", exc_info=True)
return None
if not resp.ok:
return None
data = resp.json()
return data.get("url") if data.get("media_type") == "image" else None
def render_index(lang):
"""Render the index page in ``lang``."""
config = translated_config(lang)
cards = config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
return render_template(
"pages/index.html.j2",
cards=cards,
company=config["company"],
navigation=config["navigation"],
platform=config["platform"],
lg_classes=lg_classes,
md_classes=md_classes,
apod_bg=apod_background(),
lang=lang,
lang_dir=i18n.direction(lang),
languages=i18n.LANGUAGES,
ui_strings=i18n.ui_strings(lang),
t=lambda source: i18n.catalog(lang).get(source, source),
)
@app.route("/")
@app.route('/')
def index():
"""Render the index page in the language the browser asks for."""
response = make_response(render_index(i18n.negotiate(request.accept_languages)))
response.headers["Vary"] = "Accept-Language"
return response
@app.route(f"/<any({','.join(i18n.LANGUAGES)}):lang>/")
def localized_index(lang):
"""Render the index page in an explicitly requested language."""
return render_index(lang)
return render_template("pages/index.html.j2", cards=app.config["cards"], company=app.config["company"], navigation=app.config["navigation"])
if __name__ == "__main__":
app.run(
debug=(FLASK_ENV == "development"),
host=FLASK_HOST,
port=FLASK_PORT,
use_reloader=False,
)
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=5000)

View File

@@ -1,692 +0,0 @@
---
accounts:
nasa_api_key: YOUR_REAL_KEY_HERE
name: Online Presence
description: Discover my online presence.
icon:
class: fa-solid fa-users
children:
- name: Publishing Channels
description: Platforms where I share content.
icon:
class: fas fa-newspaper
children:
- name: Microblogs
description: Stay updated with my microblog posts.
icon:
class: fa-solid fa-pen-nib
children:
- name: Mastodon
description: Follow my updates on Mastodon.
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
identifier: "@kevinveenbirkenbach@microblog.veen.world"
- name: Twitter
description: Follow me on Twitter (limited use).
icon:
class: fa-brands fa-twitter
url: https://s.veen.world/twitter
identifier: kevinbirkenbach
warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon.
alternatives:
- link: accounts.publishingchannels.microblogs.mastodon
- name: Bluesky
description: Follow me on Bluesky (coming soon).
icon:
class: fa-brands fa-bluesky
info: Bluesky is coming soon.
alternatives:
- link: accounts.publishingchannels.microblogs.mastodon
- name: Pictures
description: View my photography.
icon:
class: fa-solid fa-images
children:
- name: Pixelfed
description: Explore my photo gallery on Pixelfed.
icon:
class: fa-solid fa-camera
url: https://s.veen.world/pictures
- name: Instagram
description: Follow me on Instagram.
icon:
class: fa-brands fa-instagram
url: https://www.instagram.com/kevinveenbirkenbach/
identifier: kevinveenbirkenbach
warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives.
alternatives:
- link: accounts.publishingchannels.pictures.pixelfed
- name: Videos
description: Watch my video content.
icon:
class: fa-solid fa-video
children:
- name: Peertube
description: Discover my videos on Peertube.
icon:
class: fa-solid fa-video
url: https://s.veen.world/videos
- name: YouTube
description: Follow me on YouTube (inactive).
icon:
class: fa-brands fa-youtube
url: https://s.veen.world/youtube
warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead.
alternatives:
- link: accounts.publishingchannels.videos.peertube
- name: Blog
description: Read my articles and stories.
icon:
class: fa-solid fa-blog
url: https://blog.veen.world
- name: Code
description: Access my coding projects.
icon:
class: fa-solid fa-laptop-code
children:
- name: GitHub
description: View my GitHub repositories.
icon:
class: bi bi-github
url: https://github.com/kevinveenbirkenbach
- name: Gitea
description: Explore my self-hosted repositories.
icon:
class: fa-solid fa-code
url: https://git.veen.world/kevinveenbirkenbach
- name: Social Networks
description: Social and developer platforms.
icon:
class: fa fa-users
children:
- 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.
icon:
class: fa-brands fa-facebook
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
- name: Career Profiles
description: Professional networking profiles.
icon:
class: fa-solid fa-user-tie
children:
- name: XING
description: View my XING profile.
icon:
class: bi bi-building
url: https://s.veen.world/xing
- name: LinkedIn
description: Connect with me on LinkedIn.
icon:
class: bi bi-linkedin
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
description: My sports activities and logs.
icon:
class: fa-solid fa-running
children:
- name: Garmin
description: Explore my Garmin activity records.
icon:
class: fa-solid fa-person-running
url: https://s.veen.world/garmin
- name: Eversports
description: View my Eversports sessions.
icon:
class: fa-solid fa-dumbbell
url: https://s.veen.world/eversports
- name: Duolingo
description: Join me in language learning.
icon:
class: fa-solid fa-language
url: https://www.duolingo.com/profile/kevinbirkenbach
- name: Spotify
description: Listen to my playlists.
icon:
class: fa-brands fa-spotify
url: https://open.spotify.com/user/31vebfzbjf3p7oualis76qfpr5ty
- name: Patreon
description: Support my work on Patreon.
icon:
class: fa-brands fa-patreon
url: https://patreon.com/kevinveenbirkenbach
- name: Discourse
description: Join discussions on my forum.
icon:
class: fa-brands fa-discourse
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:
- icon:
source: https://file.infinito.nexus/assets/img/logo.png
title: Infinito.Nexus
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
shared identity, asset, and observability services. The platform that hosts
this site, including the dashboard you are looking at.
url: https://infinito.nexus
link_text: infinito.nexus
iframe: true
- 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, DevOps,
and Agile Coaching. My goal is to enhance collaboration and efficiency in organizations,
ensuring agile principles are effectively implemented for sustainable success.
url: https://www.agile-coach.world
link_text: www.agile-coach.world
iframe: true
- icon:
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
title: Personal Coach
text: Offering personalized coaching for growth and development, I utilize a blend
of hypnotherapy, mediation, and holistic techniques. My approach is tailored to
help you achieve personal and professional milestones, fostering holistic well-being.
url: https://www.personalcoach.berlin
link_text: www.personalcoach.berlin
- icon:
source: https://cloud.veen.world/s/logo_yachtmaster_512x512/download
title: Yachtmaster
text: As a Yachtmaster, I provide comprehensive sailing education, yacht delivery,
and voyage planning services. Whether you're learning to sail or need an experienced
skipper, my expertise ensures a safe and enjoyable experience on the water.
url: https://www.yachtmaster.world
link_text: www.yachtmaster.world
- icon:
source: https://cloud.veen.world/s/logo_polymath_512x512/download
title: Polymath
text: I support the evaluation and execution of complex cross-domain projects, offering
insights across land, sea, sky, and digital realms. My expertise helps clients
navigate and succeed in multifaceted environments with strategic precision.
url: https://www.crossdomain.consulting/
link_text: www.crossdomain.consulting
- icon:
source: https://cloud.veen.world/s/logo_cybermaster_512x512/download
title: Cybermaster
text: Specializing in open-source IT solutions for German SMBs, I focus on automation,
security, and reliability. My services are designed to create robust infrastructures
that streamline operations and safeguard digital assets.
url: https://www.cybermaster.space
link_text: www.cybermaster.space
- icon:
source: https://cloud.veen.world/s/logo_prompt_master_512x512/download
title: Prompt Engineer
text: Leveraging AI's power, I specialize in crafting custom prompts and creative
content for AI-driven applications. My services are aimed at businesses, creatives,
and researchers looking to harness AI technology for innovation, efficiency, and
exploring new possibilities.
url: https://promptmaster.nexus
link_text: www.promptmaster.nexus
- icon:
source: https://cloud.veen.world/s/logo_mediator_512x512/download
title: Mediator
text: Specializing in resolving interpersonal and business conflicts with empathy
and neutrality, I facilitate open communication to achieve lasting agreements
and strengthen relationships. My mediation services are designed for individuals,
teams, and organizations to foster a harmonious and productive environment.
url: https://www.mediator.veen.world
link_text: www.mediator.veen.world
- icon:
source: https://cloud.veen.world/s/logo_hypnotherapist_512x512/download
title: Hypnotherapist
text: As a certified Hypnotherapist, I offer tailored sessions to address mental
and emotional challenges through hypnosis. My approach helps unlock the subconscious
to overcome negative beliefs and stress, empowering you to activate self-healing
and embrace positive life changes.
url: https://www.hypno.veen.world
link_text: www.hypno.veen.world
#- icon:
# source: https://cloud.veen.world/s/logo_skydiver_512x512/download
# title: Aerospace Consultant
# text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
# License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
# I deliver expert consulting services. Currently training for my Private Pilot
# License, I specialize in guiding clients through aviation regulations, safety
# standards, and operational efficiency.
# url:
# link_text: Website under construction
#- icon:
# source: https://cloud.veen.world/s/logo_hunter_512x512/download
# title: Wildlife Expert
# text: As a certified hunter and wildlife coach, I offer educational programs, nature
# walks, survival trainings, and photo expeditions, merging ecological knowledge
# with nature respect. My goal is to foster sustainable conservation and enhance
# appreciation for the natural world through responsible practices.
# url:
# link_text: Website under construction
#- icon:
# source: https://cloud.veen.world/s/logo_diver_512x512/download
# title: Master Diver
# text: As a certified master diver with trainings in various specialties, I offer
# diving instruction, underwater photography, and guided dive tours. My experience
# ensures safe and enriching underwater adventures, highlighting marine conservation
# and the wonders of aquatic ecosystems.
# url:
# link_text: Website under construction
#- icon:
# source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
# title: Massage Therapist
# 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
# relaxation, focus on energy flow, personal growth, and spiritual awakening.
# url:
# link_text: Website under construction
platform:
titel: Kevin Veen-Birkenbach
subtitel: Consulting and Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_face_512x512/download
favicon:
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:
street: Afrikanische Straße 43
postal_code: DE-13351
city: Berlin
country: Germany
imprint: https://veen.world/
navigation:
header:
children:
- link: accounts.publishingchannels.children
- link: accounts.socialnetworks
- name: Contact
description: Get in touch
icon:
class: fa-solid fa-envelope
children:
- name: Email
description: Send me an email
icon:
class: fa-solid fa-envelope
children:
- name: Email
description: Send me an email
icon:
class: fa-solid fa-envelope
url: mailto:kevin@veen.world
identifier: kevin@veen.world
alternatives:
- link: navigation.header.contact.messenger.matrix
- name: Encrypted Email (PGP)
description: Download my PGP key
icon:
class: fa-solid fa-key
url: https://s.veen.world/pgp
identifier: kevin@veen.world
info: |
#### Why Use PGP?
PGP ensures your email content stays private, protecting against surveillance, data breaches, and unauthorized access.
#### Protect Your Privacy
In an age of mass data collection, PGP empowers you to communicate securely and assert control over your information. For insights on protecting your digital rights, visit the [Electronic Frontier Foundation (EFF)](https://www.eff.org/).
#### Build Trust
Encrypting emails demonstrates a commitment to privacy and security, fostering trust in professional and personal communication.
#### Stand for Security
Using PGP is more than a tool—it's a statement about valuing freedom, privacy, and the security of digital communication. Explore the principles of secure communication with [privacy guides](https://privacyguides.org/).
- name: Mobile
description: Call me
icon:
class: fa-solid fa-phone
url: "tel:+491781798023"
identifier: "+491781798023"
target: _top
- name: Messenger
description: Social and developer networks
icon:
class: fa-solid fa-comments
children:
- name: Matrix
description: Chat with me on Matrix
icon:
class: fa-solid fa-cubes
identifier: "@kevinveenbirkenbach:veen.world"
info: |
#### Why Use Matrix?
Matrix is a secure, decentralized communication platform that ensures privacy and control over your data. Learn more about [Matrix](https://matrix.org/).
#### Privacy and Security
End-to-end encryption keeps your conversations private and secure.
#### Decentralized and Open
Matrix's federated network means you can host your own server or use any provider while staying connected.
#### A Movement for Digital Freedom
By using Matrix, you support open, transparent, and secure communication.
- name: Signal
description: Message me on Signal
icon:
class: fa-brands fa-signal-messenger
identifier: "+491781798023"
warning: Signal is not hosted by me!
alternatives:
- link: navigation.header.contact.messenger.matrix
- name: Telegram
description: Message me on Telegram
icon:
class: fa-brands fa-telegram
target: _blank
url: https://t.me/kevinveenbirkenbach
identifier: kevinveenbirkenbach
warning: Telegram is not hosted by me!
alternatives:
- link: navigation.header.contact.messenger.matrix
- name: WhatsApp
description: Chat with me on WhatsApp
icon:
class: fa-brands fa-whatsapp
url: https://wa.me/491781798023
identifier: "+491781798023"
info: Consider using decentralized and privacy-respecting alternatives to maintain control over your data, improve security, and foster healthier online interactions.
alternatives:
- link: navigation.header.contact.messenger.matrix
- link: navigation.header.contact.messenger.signal
- link: navigation.header.contact.messenger.telegram
footer:
children:
- link: accounts
- name: Solution Hub
description: Curated collection of self hosted tools
icon:
class: fa-solid fa-network-wired
url:
children:
- name: Community
description: Tools to manage the community
icon:
class: fa-solid fa-users
children:
- name: Forum
description: Join the discussion
icon:
class: fa-brands fa-discourse
url: https://forum.veen.world/
- name: Learning Platform
description: Learn with my academy
icon:
class: fa-solid fa-graduation-cap
url: https://academy.veen.world/
- name: Newsletter
description: Subscribe to my newsletter
icon:
class: fa-solid fa-envelope-open-text
url: https://newsletter.veen.world/subscription/form
- name: Project Management
description: Project Management Tools
icon:
class: fa-solid fa-chart-line
children:
- name: Open Project
description: Explore my projects
icon:
class: fa-solid fa-tasks
url: https://project.veen.world/
- name: Taiga
description: View my Kanban board
icon:
class: bi bi-clipboard2-check-fill
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
icon:
class: fa-solid fa-comments
children:
- name: Elements
description: Chat with me
icon:
class: fa-solid fa-comment
url: https://element.veen.world/
- name: Big Blue Button
description: Join live events
icon:
class: fa-solid fa-video
url: https://meet.veen.world/
- name: Mailu
description: Send me a mail
icon:
class: fa-solid fa-envelope
url: https://mail.veen.world/
- name: Administration
icon:
class: fas fa-building
children:
- name: Matomo
description: Analyze with Matomo
icon:
class: fa-solid fa-chart-simple
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
description: Organize with Baserow
icon:
class: fa-solid fa-table
url: https://baserow.veen.world/
- name: Yourls
description: Find my curated links
icon:
class: bi bi-link
url: https://s.veen.world/admin/
- name: Nextcloud
description: Access my cloud storage
icon:
class: fa-solid fa-cloud
url: https://cloud.veen.world/
- name: About Me
description: All information about me
icon:
class: fa-solid fa-user
children:
- name: Logbooks
description: Access my personal logbooks (diving, flying, sailing)
icon:
class: fa-solid fa-book
children:
- name: Skydiver
description: View my skydiving logs
icon:
class: fa-solid fa-parachute-box
url: https://s.veen.world/skydiverlog
- name: Skipper
description: See my sailing records
icon:
class: fa-solid fa-sailboat
url: https://s.veen.world/meilenbuch
- name: Diver
description: Check my diving logs
icon:
class: fa-solid fa-fish
url: https://s.veen.world/diverlog
- name: Pilot
description: Review my flight logs
icon:
class: fa-solid fa-plane
url: https://s.veen.world/pilotlog
- name: Nature
description: Explore my nature logs
icon:
class: fa-solid fa-tree
url: https://s.veen.world/naturejournal
- name: Vita
description: View my CV
icon:
class: fa-solid fa-file-lines
url: https://s.veen.world/lebenslauf
- name: Languages
icon:
class: fa-solid fa-language
children:
- link: accounts.duolingo
- name: Languages Credentials
description: Check out which languages I speak
url: https://s.veen.world/languages
icon:
class: fa-solid fa-language
- name: Credentials
description: Access my certifications, degrees, and professional records
icon:
class: fa-solid fa-id-card
children:
- name: Degrees
description: View my academic degrees
icon:
class: fa-solid fa-graduation-cap
url: https://s.veen.world/degrees
- name: Certificates
description: View my training and professional development records
icon:
class: fa-solid fa-certificate
url: https://s.veen.world/certificates
- name: Certifications
description: Browse all my certifications
icon:
class: fa-solid fa-scroll
url: https://s.veen.world/certifications
- name: Skill Matrix
description: Checkout my skills
icon:
class: fa-solid fa-layer-group
url: https://s.veen.world/skillmatrix
- 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
description: Check out the imprint information
icon:
class: fa-solid fa-scale-balanced
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)

426
app/config.yaml Normal file
View File

@@ -0,0 +1,426 @@
---
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, DevOps,
and Agile Coaching. My goal is to enhance collaboration and efficiency in organizations,
ensuring agile principles are effectively implemented for sustainable success.
url: https://www.agile-coach.world
link_text: www.agile-coach.world
- icon:
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
title: Personal Coach
text: Offering personalized coaching for growth and development, I utilize a blend
of hypnotherapy, mediation, and holistic techniques. My approach is tailored to
help you achieve personal and professional milestones, fostering holistic well-being.
url: https://www.personalcoach.berlin
link_text: www.personalcoach.berlin
- icon:
source: https://cloud.veen.world/s/logo_yachtmaster_512x512/download
title: Yachtmaster
text: As a Yachtmaster, I provide comprehensive sailing education, yacht delivery,
and voyage planning services. Whether you're learning to sail or need an experienced
skipper, my expertise ensures a safe and enjoyable experience on the water.
url: https://www.yachtmaster.world
link_text: www.yachtmaster.world
- icon:
source: https://cloud.veen.world/s/logo_polymath_512x512/download
title: Polymath
text: I support the evaluation and execution of complex cross-domain projects, offering
insights across land, sea, sky, and digital realms. My expertise helps clients
navigate and succeed in multifaceted environments with strategic precision.
url: https://www.crossdomain.consulting/
link_text: www.crossdomain.consulting
- icon:
source: https://cloud.veen.world/s/logo_cybermaster_512x512/download
title: Cybermaster
text: Specializing in open-source IT solutions for German SMBs, I focus on automation,
security, and reliability. My services are designed to create robust infrastructures
that streamline operations and safeguard digital assets.
url: https://www.cybermaster.space
link_text: www.cybermaster.space
- icon:
source: https://cloud.veen.world/s/logo_prompt_master_512x512/download
title: Prompt Engineer
text: Leveraging AI's power, I specialize in crafting custom prompts and creative
content for AI-driven applications. My services are aimed at businesses, creatives,
and researchers looking to harness AI technology for innovation, efficiency, and
exploring new possibilities.
url: https://promptmaster.nexus
link_text: www.promptmaster.nexus
- icon:
source: https://cloud.veen.world/s/logo_mediator_512x512/download
title: Mediator
text: Specializing in resolving interpersonal and business conflicts with empathy
and neutrality, I facilitate open communication to achieve lasting agreements
and strengthen relationships. My mediation services are designed for individuals,
teams, and organizations to foster a harmonious and productive environment.
url: https://www.mediator.veen.world
link_text: www.mediator.veen.world
- icon:
source: https://cloud.veen.world/s/logo_hypnotherapist_512x512/download
title: Hypnotherapist
text: As a certified Hypnotherapist, I offer tailored sessions to address mental
and emotional challenges through hypnosis. My approach helps unlock the subconscious
to overcome negative beliefs and stress, empowering you to activate self-healing
and embrace positive life changes.
url: https://www.hypno.veen.world
link_text: www.hypno.veen.world
- icon:
source: https://cloud.veen.world/s/logo_skydiver_512x512/download
title: Aerospace Consultant
text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
I deliver expert consulting services. Currently training for my Private Pilot
License, I specialize in guiding clients through aviation regulations, safety
standards, and operational efficiency.
link_text: Website under construction
- icon:
source: https://cloud.veen.world/s/logo_hunter_512x512/download
title: Wildlife Expert
text: As a certified hunter and wildlife coach, I offer educational programs, nature
walks, survival trainings, and photo expeditions, merging ecological knowledge
with nature respect. My goal is to foster sustainable conservation and enhance
appreciation for the natural world through responsible practices.
link_text: Website under construction
- icon:
source: https://cloud.veen.world/s/logo_diver_512x512/download
title: Master Diver
text: As a certified master diver with trainings in various specialties, I offer
diving instruction, underwater photography, and guided dive tours. My experience
ensures safe and enriching underwater adventures, highlighting marine conservation
and the wonders of aquatic ecosystems.
link_text: Website under construction
- icon:
source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
title: Massage Therapist
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
relaxation, focus on energy flow, personal growth, and spiritual awakening.
link_text: Website under construction
accounts:
name: Accounts
description: My Accounts
icon:
class: fa-solid fa-user-group
subitems:
- name: Meta
description: Social and developer networks
icon:
class: fa-brands fa-meta
subitems:
- name: Instagram
description: Follow me on Instagram
icon:
class: fa-brands fa-instagram
url: https://www.instagram.com/kevinveenbirkenbach/
identifier: kevinveenbirkenbach
link: navigation.header.contact.whatsapp.warning
- name: Facebook
description: Like my Facebook page
icon:
class: fa-brands fa-facebook
url: https://www.facebook.com/kevinveenbirkenbach
- name: Messengers
description: Messenger Applications
icon:
class: fas fa-comments
subitems:
- link: navigation.header.contact.whatsapp
- link: navigation.header.contact.signal
- link: navigation.header.contact.telegram
- name: Carreer Profiles
icon:
class: fa-solid fa-user-tie
subitems:
- name: XING
description: Visit my XING profile
icon:
class: bi bi-building
url: https://www.xing.com/profile/Kevin_VeenBirkenbach
- name: LinkedIn
description: Connect on LinkedIn
icon:
class: bi bi-linkedin
url: https://www.linkedin.com/in/kevinveenbirkenbach
- name: Sports
description: My sport activities
icon:
class: fa-solid fa-running
subitems:
- name: Garmin
description: My Garmin activities
icon:
class: fa-solid fa-person-running
url: https://s.veen.world/garmin
- name: Eversports
description: My Eversports sessions
icon:
class: fa-solid fa-dumbbell
url: https://s.veen.world/eversports
- name: Duolingo
description: Learn with me on Duolingo
icon:
class: fa-solid fa-language
url: https://www.duolingo.com/profile/kevinbirkenbach
- name: Spotify
description: Listen to my playlists
icon:
class: fa-brands fa-spotify
url: https://open.spotify.com/user/31vebfzbjf3p7oualis76qfpr5ty
- name: Patreon
description: Support me on Patreon
icon:
class: fa-brands fa-patreon
url: https://patreon.com/kevinveenbirkenbach
company:
titel: Kevin Veen-Birkenbach
subtitel: Consulting and 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
navigation:
header:
- name: Microblog
description: Read my microblogs
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
- name: Pictures
description: View my photo gallery
icon:
class: fa-solid fa-camera
url: https://picture.veen.world/kevinveenbirkenbach
- name: Videos
description: Watch my videos
icon:
class: fa-solid fa-video
url: https://video.veen.world/a/kevinveenbirkenbach
- name: Blog
description: Read my blog
icon:
class: fa-solid fa-blog
url: https://blog.veen.world
- name: Code
icon:
class: fa-solid fa-laptop-code
description: Check out my Code
subitems:
- name: Github
description: View my GitHub profile
icon:
class: bi bi-github
url: https://github.com/kevinveenbirkenbach
- name: Gitea
description: Explore my code repositories
icon:
class: fa-solid fa-code
url: https://git.veen.world/kevinveenbirkenbach
- name: Contact
description: Get in touch
icon:
class: fa-solid fa-envelope
subitems:
- name: Email
description: Send me an email
icon:
class: fa-solid fa-envelope
url: mailto:kevin@veen.world
identifier: kevin@veen.world
alternatives:
#- link: navigation.header.contact.matrix
- name: Matrix
description: Chat with me on Matrix
icon:
class: fa-solid fa-cubes
identifier: "@kevinveenbirkenbach:veen.world"
info: |
#### Why Use Matrix?
Matrix is a secure, decentralized communication platform that ensures privacy and control over your data. Learn more about [Matrix](https://matrix.org/).
#### Privacy and Security
End-to-end encryption keeps your conversations private and secure.
#### Decentralized and Open
Matrix's federated network means you can host your own server or use any provider while staying connected.
#### A Movement for Digital Freedom
By using Matrix, you support open, transparent, and secure communication.
- name: Mobile
description: Call me
icon:
class: fa-solid fa-phone
url: "tel:+491781798023"
identifier: "+491781798023"
target: _top
- name: Encrypted Email (PGP)
description: Download my PGP key
icon:
class: fa-solid fa-key
url: https://s.veen.world/pgp
identifier: kevin@veen.world
info: |
#### Why Use PGP?
PGP ensures your email content stays private, protecting against surveillance, data breaches, and unauthorized access.
#### Protect Your Privacy
In an age of mass data collection, PGP empowers you to communicate securely and assert control over your information. For insights on protecting your digital rights, visit the [Electronic Frontier Foundation (EFF)](https://www.eff.org/).
#### Build Trust
Encrypting emails demonstrates a commitment to privacy and security, fostering trust in professional and personal communication.
#### Stand for Security
Using PGP is more than a tool—it's a statement about valuing freedom, privacy, and the security of digital communication. Explore the principles of secure communication with [privacy guides](https://privacyguides.org/).
- name: Signal
description: Message me on Signal
icon:
class: fa-brands fa-signal-messenger
identifier: "+491781798023"
warning: Signal is not hosted by me!
alternatives:
#- link: navigation.header.contact.matrix
- name: Telegram
description: Message me on Telegram
icon:
class: fa-brands fa-telegram
target: _blank
url: https://t.me/kevinveenbirkenbach
identifier: kevinveenbirkenbach
warning: Telegram is not hosted by me!
alternatives:
#- link: navigation.header.contact.matrix
- name: WhatsApp
description: Chat with me on WhatsApp
icon:
class: fa-brands fa-whatsapp
url: https://wa.me/491781798023
identifier: "+491781798023"
alternatives:
#- link: navigation.header.contact.matrix
warning: |
Using software and platforms from the Meta corporation (e.g., Facebook, Instagram, WhatsApp) may compromise your data privacy and digital freedom due to centralized control, extensive data collection practices, and inconsistent moderation policies. These platforms often fail to adequately address harmful content, misinformation, and abuse.
footer:
- link: accounts
- name: Community
description: My presence in the Fediverse
icon:
class: fa-solid fa-users
subitems:
- name: Forum
description: Join the discussion
icon:
class: fa-brands fa-discourse
url: https://forum.veen.world/u/kevinveenbirkenbach
- name: Newsletter
description: Subscribe to my newsletter
icon:
class: fa-solid fa-envelope-open-text
url: https://newsletter.veen.world/subscription/form
- name: Work Hub
description: Curated collection of self hosted tools for work, organization, and learning.
icon:
class: fa-solid fa-toolbox
subitems:
- name: Open Project
description: Explore my projects
icon:
class: fa-solid fa-chart-line
url: https://project.veen.world/
- name: Taiga
description: View my Kanban board
icon:
class: bi bi-clipboard2-check-fill
url: https://kanban.veen.world/
- name: Matomo
description: Analyze with Matomo
icon:
class: fa-solid fa-chart-simple
url: https://matomo.veen.world/
- name: Baserow
description: Organize with Baserow
icon:
class: fa-solid fa-table
url: https://baserow.veen.world/
- name: Elements
description: Chat with me
icon:
class: fa-solid fa-comment
url: https://element.veen.world/
- name: Big Blue Button
description: Join live events
icon:
class: fa-solid fa-video
url: https://meet.veen.world/
- name: Mailu
description: Send me a mail
icon:
class: fa-solid fa-envelope
url: https://mail.veen.world/
- name: Moodel
description: Learn with my academy
icon:
class: fa-solid fa-graduation-cap
url: https://academy.veen.world/
- name: Yourls
description: Find my curated links
icon:
class: bi bi-link
url: https://s.veen.world/admin/
- name: Nextcloud
description: Access my cloud storage
icon:
class: fa-solid fa-cloud
url: https://cloud.veen.world/
- name: Logbooks
description: My activity logs
icon:
class: fa-solid fa-book
subitems:
- name: Skydiver
description: View my skydiving logs
icon:
class: fa-solid fa-parachute-box
url: https://s.veen.world/skydiverlog
- name: Skipper
description: See my sailing records
icon:
class: fa-solid fa-sailboat
url: https://s.veen.world/meilenbuch
- name: Diver
description: Check my diving logs
icon:
class: fa-solid fa-fish
url: https://s.veen.world/diverlog
- name: Pilot
description: Review my flight logs
icon:
class: fa-solid fa-plane
url: https://s.veen.world/pilotlog
- name: Nature
description: Explore my nature logs
icon:
class: fa-solid fa-tree
url: https://s.veen.world/naturejournal
- name: Vita
description: View my CV and professional background
icon:
class: fa-solid fa-file-lines
url: https://s.veen.world/lebenslauf
- name: Imprint
icon:
class: fa-solid fa-scale-balanced
url: https://s.veen.world/imprint

View File

@@ -1,19 +0,0 @@
// 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

@@ -1,90 +0,0 @@
// 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

@@ -1,85 +0,0 @@
// 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

@@ -1,61 +0,0 @@
// 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

@@ -1,243 +0,0 @@
// cypress/e2e/i18n.spec.js
const GERMAN_BROWSER = { headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } };
describe('Language negotiation', () => {
it('serves English to an English browser', () => {
cy.visit('/', { headers: { 'Accept-Language': 'en-US,en;q=0.9' } });
cy.get('html').should('have.attr', 'lang', 'en');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Imprint');
});
it('serves German to a German browser', () => {
cy.visit('/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'de');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Impressum');
});
it('falls back to English for an unsupported browser language', () => {
cy.visit('/', { headers: { 'Accept-Language': 'xx-XX' } });
cy.get('html').should('have.attr', 'lang', 'en');
});
it('lets the URL prefix override the browser language', () => {
cy.visit('/fr/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'fr');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Mentions légales');
});
it('rejects an unsupported language code', () => {
cy.request({ url: '/xx/', failOnStatusCode: false })
.its('status')
.should('eq', 404);
});
it('does not turn unrelated single-segment paths into redirects', () => {
['/robots.txt', '/favicon.ico', '/sitemap.xml'].forEach(path => {
cy.request({ url: path, failOnStatusCode: false, followRedirect: false })
.its('status')
.should('eq', 404);
});
});
it('marks the negotiated route as varying by language', () => {
cy.request('/')
.its('headers.vary')
.should('contain', 'Accept-Language');
});
});
describe('Language switcher', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/en/');
});
it('names the active language and offers every ISO 639-1 code', () => {
cy.get('#navbarDropdownLanguage')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('contain.text', 'English');
cy.get('#navbarDropdownLanguage')
.parent('.nav-item')
.find('> .dropdown-menu a.dropdown-item')
.should('have.length', 184);
});
it('marks the active language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item.active[hreflang="en"]').should('exist');
});
it('navigates to the chosen language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item[hreflang="de"]')
.should('have.text', 'Deutsch')
.click();
cy.url().should('match', /\/de\/$/);
cy.get('html').should('have.attr', 'lang', 'de');
});
});
describe('Translated interface strings', () => {
it('translates the strings rendered by the templates', () => {
cy.visit('/de/');
cy.get('#dynamicCopyButton').should('have.text', 'Kopieren');
cy.get('#dynamicChildrenSection h6').should('have.text', 'Optionen:');
cy.get('#dynamicAlternativesSection h6').should('have.text', 'Alternativen:');
cy.get('.modal-footer button').should('have.text', 'Schließen');
});
it('exposes the catalogue to client-side code', () => {
cy.visit('/de/');
cy.window().its('I18N').should('deep.include', {
Open: 'Öffnen',
'Open Link': 'Link öffnen',
'Identifier copied to clipboard!': 'Kennung in die Zwischenablage kopiert!',
});
});
it('leaves the catalogue untranslated in the source language', () => {
cy.visit('/en/');
cy.window().its('I18N').should('deep.include', { Open: 'Open' });
});
});
describe('Strings translated by modal.js', () => {
const item = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } },
],
};
beforeEach(() => {
cy.visit('/de/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
it('translates the button of a list entry', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicAlternativesList button').should('have.text', 'Öffnen');
});
it('translates the link label when the entry has no description', () => {
cy.window().invoke('openDynamicPopup', {
...item,
url: 'https://example.com',
description: null,
});
cy.get('#dynamicModalLinkHref').should('have.text', 'Link öffnen');
});
it('translates the clipboard confirmation', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicCopyButton').click();
cy.window()
.its('alert')
.should('have.been.calledWith', 'Kennung in die Zwischenablage kopiert!');
});
});
describe('Right-to-left languages', () => {
it('flips the document and loads the RTL stylesheet', () => {
cy.visit('/ar/');
cy.get('html').should('have.attr', 'dir', 'rtl');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('exist');
cy.get('link[href*="vendor/bootstrap/css/bootstrap.min.css"]').should('not.exist');
cy.get('body').should('have.css', 'direction', 'rtl');
});
it('keeps left-to-right languages on the default stylesheet', () => {
cy.visit('/en/');
cy.get('html').should('have.attr', 'dir', 'ltr');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('not.exist');
});
it('actually serves the RTL stylesheet it links to', () => {
cy.visit('/ar/');
cy.get('link[href*="bootstrap.rtl.min.css"]')
.should('have.attr', 'href')
.then(href => {
cy.request(href).its('status').should('eq', 200);
});
});
});
describe('Translated interface details', () => {
it('translates the strings only a screen reader sees', () => {
cy.visit('/de/');
cy.get('#dynamicModal .btn-close').should('have.attr', 'aria-label', 'Schließen');
});
it('translates the alert headings', () => {
cy.visit('/fr/');
cy.get('#dynamicModalWarning h5').should('contain.text', 'Avertissement');
cy.get('#dynamicModalInfo h5').should('contain.text', 'Informations');
});
it('translates the language switcher tooltip', () => {
cy.visit('/de/');
cy.get('#navbarDropdownLanguage').should('have.attr', 'title', 'Sprache');
});
it('tags each switcher entry with its own language', () => {
cy.visit('/en/');
cy.get('.dropdown-menu a.dropdown-item[hreflang="ja"]').should(
'have.attr',
'lang',
'ja',
);
});
it('scrolls inside the language menu instead of past the page', () => {
cy.viewport(1280, 720);
cy.visit('/en/');
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu.language-menu').should($menu => {
const menu = $menu[0];
expect(menu.scrollHeight, 'taller than it shows').to.be.greaterThan(
menu.clientHeight,
);
expect(menu.getBoundingClientRect().height).to.be.lessThan(720);
expect(getComputedStyle(menu).overflowY).to.eq('auto');
});
});
it('offers the switcher in the header only', () => {
cy.viewport(1280, 720);
cy.visit('/en/');
cy.get('#navbarNavheader #navbarDropdownLanguage').should('exist');
cy.get('#navbarNavfooter #navbarDropdownLanguage').should('not.exist');
});
});
describe('Search engine metadata', () => {
beforeEach(() => {
cy.visit('/de/');
});
it('declares an alternate for every language plus a default', () => {
cy.get('link[rel="alternate"][hreflang]').should('have.length', 185);
cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist');
cy.get('link[rel="alternate"][hreflang="ja"]')
.should('have.attr', 'href')
.and('match', /\/ja\/$/);
});
it('points the canonical URL at the language actually served', () => {
cy.get('link[rel="canonical"]').should('have.attr', 'href').and('match', /\/de\/$/);
});
});

View File

@@ -1,46 +0,0 @@
// 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

@@ -1,263 +0,0 @@
// cypress/e2e/injection.spec.js
describe('Untrusted content in the modal', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
};
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 });
}
describe('markdown rendered into innerHTML', () => {
it('strips a plain script URL', () => {
open({
warning: '[click me](javascript:window.__xss = true)',
info: '![x](data:text/html;base64,PHNjcmlwdD4=)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'click me');
cy.get('#dynamicModalInfoText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL hidden behind character references', () => {
open({
warning:
'[a](&#106;avascript:window.__xss=1) [b](&#x6A;avascript:window.__xss=1)',
info: '[c](java&Tab;script:window.__xss=1) [d](java&NewLine;script:window.__xss=1)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL written as a reference-style link', () => {
open({
warning: '[click me][ref]\n\n[ref]: &#106;avascript:window.__xss=1',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML', () => {
open({
warning: '<img src=x onerror="window.__xss = true">',
info: '<a href="javascript:window.__xss = true">x</a>',
});
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML used as the text of a stripped link', () => {
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('keeps a relative link', () => {
open({ warning: 'See [the notes](/#anchor)' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', '/#anchor');
});
it('keeps the text of a link it strips', () => {
open({ warning: '[read this](javascript:window.__xss=1)' });
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
cy.window().should('not.have.property', '__xss');
});
it('keeps ordinary markdown', () => {
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', 'https://matrix.org/');
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
});
});
describe('values interpolated outside markdown', () => {
it('does not treat an interface string as markup', () => {
cy.window().then(win => {
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
it('falls back to the English source when a string is missing', () => {
cy.window().then(win => {
delete win.I18N;
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
});
it('renders no placeholder for a missing name', () => {
open({ name: undefined });
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
});
it('does not treat the name or the icon class as markup', () => {
open({
name: '<img src=x onerror="window.__xss = true">',
icon: { class: 'fa" onmouseover="window.__xss = true' },
alternatives: [
{
name: '<img src=y onerror="window.__xss = true">',
identifier: 'ALT1',
icon: { class: 'fa-alt' },
},
],
});
cy.get('#dynamicModalLabel').find('img').should('not.exist');
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
});
describe('the link the modal offers', () => {
it('drops a URL that uses an unsafe scheme', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
cy.window().should('not.have.property', '__xss');
});
it('keeps an ordinary URL', () => {
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('keeps a URL that carries surrounding whitespace', () => {
open({ url: ' https://example.com ', description: 'Good' });
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
});
it('keeps a mailto URL', () => {
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'mailto:kevin@veen.world',
);
});
it('restores the link after a popup whose URL was dropped', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('does not let one popup iframe handler outlive it', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
open({ url: 'https://b.test/', description: 'B' });
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
cy.get('#dynamicModalLinkHref').should($anchor => {
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
});
});
it('opens the current popup URL, not an earlier one', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
open({ url: 'https://b.test/', description: 'B', iframe: true });
cy.get('#dynamicModalLinkHref').click();
cy.get('#main')
.find('iframe', { timeout: 4000 })
.should('have.attr', 'src', 'https://b.test/');
});
});
});
describe('Untrusted content reaching the iframe', () => {
const AFTER_THE_FADE = 3000;
it('refuses to open a script URL handed over by the modal', () => {
cy.visit('/');
cy.window().invoke('openDynamicPopup', {
name: 'Bad',
icon: { class: 'fa fa-test' },
url: 'javascript:window.__xss = true',
description: 'Watch',
iframe: true,
});
cy.get('#dynamicModalLinkHref').click({ force: true });
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('refuses a script URL supplied through the query string', () => {
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('still opens an ordinary URL from the query string', () => {
cy.visit('/?iframe=https://example.com/');
cy.get('#main')
.find('iframe', { timeout: AFTER_THE_FADE })
.should('have.attr', 'src', 'https://example.com/');
});
});

View File

@@ -1,196 +0,0 @@
// cypress/e2e/menu.spec.js
describe('Navigation dropdowns', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/');
});
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
.first()
.as('toggle')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('have.attr', 'aria-expanded', 'false');
cy.get('@toggle').then($toggle => {
cy.window().then(win => {
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
});
});
cy.get('@toggle')
.parent('.nav-item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show')
.should('not.be.visible');
cy.get('@toggle').click();
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
it('flips footer dropdowns upward where there is more space above', () => {
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
.first()
.as('toggle');
cy.get('@toggle')
.parent('.nav-item')
.as('item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show');
// Make sure the footer sits at the bottom of the viewport before clicking
// — otherwise the toggle could land near the top and chooseDirection would
// keep .dropdown (more space below than above).
// ensureScrollable:false because on short pages the body isn't scrollable
// and the footer is already in view (which is fine for this test).
cy.scrollTo('bottom', { ensureScrollable: false });
cy.get('@toggle').then($toggle => {
const rect = $toggle[0].getBoundingClientRect();
expect(rect.top, 'toggle is in the lower half of the viewport')
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
});
cy.get('@toggle').click({ scrollBehavior: false });
cy.get('@item').should('have.class', 'dropup');
cy.get('@item').should('not.have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
});
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

@@ -1,130 +0,0 @@
// 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

@@ -1,32 +0,0 @@
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

@@ -1,130 +0,0 @@
// 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

@@ -1,78 +0,0 @@
'use strict';
/**
* Correctness only — eslint's recommended set, no stylistic rules.
*
* The browser scripts are plain <script> tags sharing one global scope: each
* file declares some functions and calls others declared elsewhere. That is why
* they are listed as globals and why no-redeclare is off — the declaring file
* would otherwise be reported for defining its own function.
*/
const js = require('@eslint/js');
const globals = require('globals');
// Vendored libraries plus the functions static/js files call across each other.
// Keep this to names that really cross a file boundary. Every superfluous
// entry is a permanent no-undef blind spot for a typo of that name.
const SHARED = {
bootstrap: 'readonly',
marked: 'readonly',
$: 'readonly',
jQuery: 'readonly',
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',
setFullWidth: 'readonly',
initFullWidthFromUrl: 'readonly',
adjustScrollContainerHeight: 'readonly',
updateCustomScrollbar: 'readonly',
};
module.exports = [
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
{
files: ['static/js/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: { ...globals.browser, ...SHARED },
},
rules: {
...js.configs.recommended.rules,
'no-redeclare': 'off',
// vars: 'local' — a top-level function here is the API other files and
// the templates call, so only unused locals are a defect.
'no-unused-vars': [
'error',
{ vars: 'local', args: 'none', caughtErrors: 'none' },
],
},
},
{
files: ['cypress/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
...globals.mocha,
cy: 'readonly',
Cypress: 'readonly',
expect: 'readonly',
assert: 'readonly',
},
},
rules: js.configs.recommended.rules,
},
{
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: globals.node,
},
rules: js.configs.recommended.rules,
},
];

View File

@@ -1,46 +0,0 @@
# Strings utils/i18n_sync.py stores as themselves instead of translating.
# One per line; blank lines and lines starting with # are ignored.
Baserow
Big Blue Button
Bluesky
Buy me a Coffee
Discourse
Duolingo
Eversports
Facebook
Friendica
Garmin
Gitea
GitHub
GitHub Sponsors
Infinito.Nexus
Instagram
Keycloak
LDAP
LinkedIn
Mailu
Mastodon
Matomo
Matrix
Nextcloud
Open Project
Patreon
PayPal
Peertube
Pixelfed
Signal
Snipe IT
Spotify
Taiga
Telegram
Twitter
WhatsApp
XING
YouTube
Yourls
Zoom
freelancermap.de
malt
phpMyAdmin
upwork.com

View File

@@ -1,11 +0,0 @@
Alternatives: بدائل
Close: إغلاق
Copy: نسخ
Identifier copied to clipboard!: تم نسخ المعرّف إلى الحافظة!
Imprint: معلومات قانونية
Information: معلومات
Language: اللغة
Open: فتح
Open Link: فتح الرابط
Options: خيارات
Warning: تحذير

View File

@@ -1,11 +0,0 @@
Alternatives: অন্যান্য বিকল্প
Close: বন্ধ করুন
Copy: অনুলিপি
Identifier copied to clipboard!: শনাক্তকারী ক্লিপবোর্ডে অনুলিপি করা হয়েছে!
Imprint: আইনি তথ্য
Information: তথ্য
Language: ভাষা
Open: খুলুন
Open Link: লিঙ্ক খুলুন
Options: বিকল্প
Warning: সতর্কতা

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativy
Close: Zavřít
Copy: Kopírovat
Identifier copied to clipboard!: Identifikátor zkopírován do schránky!
Imprint: Tiráž
Information: Informace
Language: Jazyk
Open: Otevřít
Open Link: Otevřít odkaz
Options: Možnosti
Warning: Upozornění

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativer
Close: Luk
Copy: Kopiér
Identifier copied to clipboard!: Identifikator kopieret til udklipsholder!
Imprint: Juridisk information
Information: Information
Language: Sprog
Open: Åbn
Open Link: Åbn link
Options: Valgmuligheder
Warning: Advarsel

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativen
Close: Schließen
Copy: Kopieren
Identifier copied to clipboard!: Kennung in die Zwischenablage kopiert!
Imprint: Impressum
Information: Information
Language: Sprache
Open: Öffnen
Open Link: Link öffnen
Options: Optionen
Warning: Warnung

View File

@@ -1,11 +0,0 @@
Alternatives: Εναλλακτικές
Close: Κλείσιμο
Copy: Αντιγραφή
Identifier copied to clipboard!: Το αναγνωριστικό αντιγράφηκε στο πρόχειρο!
Imprint: Νομικές πληροφορίες
Information: Πληροφορίες
Language: Γλώσσα
Open: Άνοιγμα
Open Link: Άνοιγμα συνδέσμου
Options: Επιλογές
Warning: Προειδοποίηση

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativas
Close: Cerrar
Copy: Copiar
Identifier copied to clipboard!: ¡Identificador copiado al portapapeles!
Imprint: Aviso legal
Information: Información
Language: Idioma
Open: Abrir
Open Link: Abrir enlace
Options: Opciones
Warning: Advertencia

View File

@@ -1,11 +0,0 @@
Alternatives: جایگزین‌ها
Close: بستن
Copy: کپی
Identifier copied to clipboard!: شناسه در کلیپ‌بورد کپی شد!
Imprint: اطلاعات حقوقی
Information: اطلاعات
Language: زبان
Open: باز کردن
Open Link: باز کردن پیوند
Options: گزینه‌ها
Warning: هشدار

View File

@@ -1,11 +0,0 @@
Alternatives: Vaihtoehdot
Close: Sulje
Copy: Kopioi
Identifier copied to clipboard!: Tunniste kopioitu leikepöydälle!
Imprint: Oikeudelliset tiedot
Information: Tiedot
Language: Kieli
Open: Avaa
Open Link: Avaa linkki
Options: Valinnat
Warning: Varoitus

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatives
Close: Fermer
Copy: Copier
Identifier copied to clipboard!: Identifiant copié dans le presse-papiers !
Imprint: Mentions légales
Information: Informations
Language: Langue
Open: Ouvrir
Open Link: Ouvrir le lien
Options: Options
Warning: Avertissement

View File

@@ -1,11 +0,0 @@
Alternatives: חלופות
Close: סגור
Copy: העתק
Identifier copied to clipboard!: המזהה הועתק ללוח!
Imprint: מידע משפטי
Information: מידע
Language: שפה
Open: פתח
Open Link: פתח קישור
Options: אפשרויות
Warning: אזהרה

View File

@@ -1,11 +0,0 @@
Alternatives: अन्य विकल्प
Close: बंद करें
Copy: कॉपी करें
Identifier copied to clipboard!: पहचानकर्ता क्लिपबोर्ड पर कॉपी हो गया!
Imprint: कानूनी सूचना
Information: जानकारी
Language: भाषा
Open: खोलें
Open Link: लिंक खोलें
Options: विकल्प
Warning: चेतावनी

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatívák
Close: Bezárás
Copy: Másolás
Identifier copied to clipboard!: Az azonosító a vágólapra másolva!
Imprint: Impresszum
Information: Információ
Language: Nyelv
Open: Megnyitás
Open Link: Hivatkozás megnyitása
Options: Lehetőségek
Warning: Figyelmeztetés

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatif
Close: Tutup
Copy: Salin
Identifier copied to clipboard!: Pengenal disalin ke papan klip!
Imprint: Informasi hukum
Information: Informasi
Language: Bahasa
Open: Buka
Open Link: Buka tautan
Options: Opsi
Warning: Peringatan

View File

@@ -1,11 +0,0 @@
Alternatives: Alternative
Close: Chiudi
Copy: Copia
Identifier copied to clipboard!: Identificatore copiato negli appunti!
Imprint: Note legali
Information: Informazioni
Language: Lingua
Open: Apri
Open Link: Apri collegamento
Options: Opzioni
Warning: Avviso

View File

@@ -1,11 +0,0 @@
Alternatives: 代替
Close: 閉じる
Copy: コピー
Identifier copied to clipboard!: 識別子をクリップボードにコピーしました!
Imprint: 法的情報
Information: 情報
Language: 言語
Open: 開く
Open Link: リンクを開く
Options: オプション
Warning: 警告

View File

@@ -1,11 +0,0 @@
Alternatives: 대안
Close: 닫기
Copy: 복사
Identifier copied to clipboard!: 식별자를 클립보드에 복사했습니다!
Imprint: 법적 고지
Information: 정보
Language: 언어
Open: 열기
Open Link: 링크 열기
Options: 옵션
Warning: 경고

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatieven
Close: Sluiten
Copy: Kopiëren
Identifier copied to clipboard!: Identificatie gekopieerd naar klembord!
Imprint: Colofon
Information: Informatie
Language: Taal
Open: Openen
Open Link: Link openen
Options: Opties
Warning: Waarschuwing

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatywy
Close: Zamknij
Copy: Kopiuj
Identifier copied to clipboard!: Identyfikator skopiowany do schowka!
Imprint: Nota prawna
Information: Informacja
Language: Język
Open: Otwórz
Open Link: Otwórz link
Options: Opcje
Warning: Ostrzeżenie

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativas
Close: Fechar
Copy: Copiar
Identifier copied to clipboard!: Identificador copiado para a área de transferência!
Imprint: Aviso legal
Information: Informação
Language: Idioma
Open: Abrir
Open Link: Abrir link
Options: Opções
Warning: Aviso

View File

@@ -1,11 +0,0 @@
Alternatives: Alternative
Close: Închide
Copy: Copiază
Identifier copied to clipboard!: Identificator copiat în clipboard!
Imprint: Notă legală
Information: Informații
Language: Limbă
Open: Deschide
Open Link: Deschide linkul
Options: Opțiuni
Warning: Avertisment

View File

@@ -1,11 +0,0 @@
Alternatives: Альтернативы
Close: Закрыть
Copy: Копировать
Identifier copied to clipboard!: Идентификатор скопирован в буфер обмена!
Imprint: Выходные данные
Information: Информация
Language: Язык
Open: Открыть
Open Link: Открыть ссылку
Options: Варианты
Warning: Предупреждение

View File

@@ -1,11 +0,0 @@
Alternatives: Alternativ
Close: Stäng
Copy: Kopiera
Identifier copied to clipboard!: Identifierare kopierad till urklipp!
Imprint: Juridisk information
Information: Information
Language: Språk
Open: Öppna
Open Link: Öppna länk
Options: Val
Warning: Varning

View File

@@ -1,11 +0,0 @@
Alternatives: ทางเลือกอื่น
Close: ปิด
Copy: คัดลอก
Identifier copied to clipboard!: คัดลอกตัวระบุไปยังคลิปบอร์ดแล้ว!
Imprint: ข้อมูลทางกฎหมาย
Information: ข้อมูล
Language: ภาษา
Open: เปิด
Open Link: เปิดลิงก์
Options: ตัวเลือก
Warning: คำเตือน

View File

@@ -1,11 +0,0 @@
Alternatives: Alternatifler
Close: Kapat
Copy: Kopyala
Identifier copied to clipboard!: Tanımlayıcı panoya kopyalandı!
Imprint: Künye
Information: Bilgi
Language: Dil
Open:
Open Link: Bağlantıyı
Options: Seçenekler
Warning: Uyarı

View File

@@ -1,11 +0,0 @@
Alternatives: Альтернативи
Close: Закрити
Copy: Копіювати
Identifier copied to clipboard!: Ідентифікатор скопійовано в буфер обміну!
Imprint: Вихідні дані
Information: Інформація
Language: Мова
Open: Відкрити
Open Link: Відкрити посилання
Options: Варіанти
Warning: Попередження

View File

@@ -1,11 +0,0 @@
Alternatives: متبادل
Close: بند کریں
Copy: نقل کریں
Identifier copied to clipboard!: شناخت کنندہ کلپ بورڈ پر نقل ہو گیا!
Imprint: قانونی معلومات
Information: معلومات
Language: زبان
Open: کھولیں
Open Link: لنک کھولیں
Options: اختیارات
Warning: انتباہ

View File

@@ -1,11 +0,0 @@
Alternatives: Lựa chọn thay thế
Close: Đóng
Copy: Sao chép
Identifier copied to clipboard!: Đã sao chép mã định danh vào bộ nhớ tạm!
Imprint: Thông tin pháp lý
Information: Thông tin
Language: Ngôn ngữ
Open: Mở
Open Link: Mở liên kết
Options: Tùy chọn
Warning: Cảnh báo

View File

@@ -1,11 +0,0 @@
Alternatives: 替代方案
Close: 关闭
Copy: 复制
Identifier copied to clipboard!: 标识符已复制到剪贴板!
Imprint: 法律声明
Information: 信息
Language: 语言
Open: 打开
Open Link: 打开链接
Options: 选项
Warning: 警告

View File

@@ -1,19 +0,0 @@
{
"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": {
"@eslint/js": "^10.0.1",
"cypress": "^14.5.1",
"eslint": "^10.9.0",
"globals": "^17.11.0"
},
"scripts": {
"build": "node scripts/copy-vendor.js",
"postinstall": "node scripts/copy-vendor.js"
}
}

3
app/requirements.txt Normal file
View File

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

View File

@@ -1,75 +0,0 @@
'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', 'css', 'bootstrap.rtl.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.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

@@ -1,31 +0,0 @@
/* 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

@@ -1,12 +1,7 @@
@import url("navigation.css");
/* General link styles */
a {
text-decoration: none;
color: #000000;
}
/* Header styles */
.header img {
float: right;
width: 100px;
@@ -16,203 +11,129 @@ a {
.header h1 {
position: relative;
}
/* Equal-height container using flexbox */
.equal-height {
display: flex;
flex: 1;
}
/* Subtle shadow effect */
.navbar, .card, .dropdown-menu {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* Card styles */
.navbar, .card {
flex: 1; /* Ensures cards fill the height of their container */
border-radius: 5px; /* Rounded corners */
border: 1px solid #ccc; /* Optional border color */
padding: 10px; /* Inner spacing */
color: #000000 !important;
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 {
display: flex;
flex-direction: column;
align-items: center; /* Center content horizontally */
text-align: center; /* Center text alignment */
align-items: center; /* Zentriert die Inhalte horizontal */
text-align: center; /* Zentriert den Text */
}
.card-icon {
display: flex;
justify-content: center; /* Center the icon horizontally */
justify-content: center; /* Zentriert das Icon horizontal */
}
.card-text,
.card ul {
text-align: left; /* Align text to the left */
text-align: left; /* Stellt sicher, dass der Text linksbündig ist */
}
.card-column {
.card{
flex: 1; /* Stellt sicher, dass die Karten die ganze Höhe ihrer Container ausfüllen */
border-width: 3px;
/*border-color: #000000;*/
}
h3.card-title{
font-size: 1.3em;
}
.card .stretched-link{
font-size: 0.7em;
}
.card-column{
padding-top: 12px;
padding-bottom: 12px;
}
.card .stretched-link {
font-size: 0.7em;
}
h3.card-title {
h3.footer-title{
font-size: 1.3em;
}
/* Footer styles */
.footer {
margin-top: 12px;
text-align: center;
font-size: 0.7em;
font-size: 0.7em;
}
.footer p,
.footer h3 {
margin: 0;
padding: 0;
.footer p, h3{
margin: 0px;
padding: 0px;
}
h3.footer-title {
font-size: 1.3em;
.dropdown-menu {
position: absolute !important;
}
.card-img-top i, .card-img-top svg{
font-size: 100px;
fill: currentColor;
width: 100px;
height: auto;
.dropdown-menu-footer {
position: absolute !important;
top: auto !important;
bottom: 100%; /* Positioniert das Menü über dem Auslöser */
transform: translateY(-10px); /* Optional: Sanfter Abstand */
}
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.
overflow is intentionally NOT set here — overflow-x:auto would
implicitly clip overflow-y too and hide dropdown menus that open
below the navbar. */
div#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap;
}
.dropdown-menu.language-menu {
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain;
}
main, footer, header, nav {
.dropdown-submenu {
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;
list-style: none;
}
header{
padding: 12px;
.navbar, .card {
border-radius: 5px; /* Runde Ecken */
border: 1px solid #ccc; /* Optionale Rahmenfarbe */
padding: 10px; /* Optionaler Abstand innen */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); /* Optionale Schatteneffekte */
color: #000000 !important;
background-color: #f9f9f9;
}
header,
footer {
left: 0;
right: 0;
bottom: 0;
top: 0;
.navbar-nav {
padding: 0;
margin: 0;
z-index: 1030;
background-color: var(--bs-light);
}
/* Stellt sicher, dass Submenüs korrekt positioniert sind */
.dropdown-submenu {
position: relative;
}
/* at the end of default.css */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
.dropdown-submenu > .dropdown-menu {
position: absolute;
top: 0;
left: 100%; /* Positioniert das Submenü rechts vom Hauptmenü */
margin-top: -1px;
}
iframe{
margin-bottom: -10px;
.dropdown-menu.collapse {
display: none;
}
.container-fluid {
max-width: 100% !important;
.dropdown-menu.collapse.show {
display: block;
}
: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 {
/* Standardmäßig sind die Submenüs ausgeblendet */
.dropdown-submenu .dropdown-menu {
display: none;
opacity: 0;
max-width: 0;
overflow: hidden;
transition: opacity var(--anim-duration) ease-in-out,
max-width var(--anim-duration) ease-in-out;
transition: opacity 0.3s ease-in-out;
position: absolute;
left: 100%;
top: 0;
}
#navbar_logo.visible {
opacity: 1 !important;
max-width: 300px;
/* Beim Hover auf das Submenü-Element wird das Menü angezeigt */
.dropdown-submenu:hover > .dropdown-menu {
display: block;
opacity: 1;
z-index: 1050;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {
/* 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;
/* Um sicherzustellen, dass es nicht sofort verschwindet */
.dropdown-submenu:hover > .dropdown-menu:hover {
display: block;
opacity: 1;
}
/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
so dropdown menus can escape the header in normal mode. */
body.fullscreen header,
body.fullscreen footer {
overflow: hidden;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}

View File

@@ -1,34 +0,0 @@
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
.nav-item.dropdown > .dropdown-menu,
.nav-item.dropup > .dropdown-menu {
position: absolute;
left: 0;
z-index: 1050;
}
.nav-item.dropdown > .dropdown-menu {
top: 100%;
bottom: auto;
}
.nav-item.dropup > .dropdown-menu {
top: auto;
bottom: 100%;
}
/* Submenu position */
.dropdown-submenu > .dropdown-menu {
position: absolute;
top: 0;
left: 100%; /* Opens to the right */
z-index: 1050;
}
/* Ensure a smooth transition */
.dropdown-menu {
transition: all 0.3s ease-in-out;
}
nav.navbar {
border-radius: 0;
}

View File

@@ -1,108 +0,0 @@
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;
}
});

View File

@@ -1,110 +0,0 @@
/**
* 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

@@ -1,42 +0,0 @@
/**
* 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;

View File

@@ -1,193 +0,0 @@
// 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) {
if (!isSafeUrl(url)) {
return;
}
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,49 +1,16 @@
function t(source) {
return (window.I18N || {})[source] || source;
}
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
function isSafeUrl(url) {
const probe = document.createElement('a');
probe.href = String(url == null ? '' : url);
return SAFE_URL_SCHEMES.includes(probe.protocol);
}
function iconAndName(item) {
const nodes = [];
if (item.icon && item.icon.class) {
const icon = document.createElement('i');
icon.className = item.icon.class;
nodes.push(icon, document.createTextNode(' '));
}
nodes.push(document.createTextNode(item.name == null ? '' : item.name));
return nodes;
}
function renderMarkdown(content) {
const escaped = String(content).replace(/</g, '&lt;').replace(/>/g, '&gt;');
const parsed = new DOMParser().parseFromString(marked.parse(escaped), 'text/html');
parsed.querySelectorAll('a[href]').forEach((anchor) => {
if (!SAFE_URL_SCHEMES.includes(anchor.protocol)) {
anchor.replaceWith(...anchor.childNodes);
}
});
parsed.querySelectorAll('img[src]').forEach((image) => {
if (!SAFE_URL_SCHEMES.includes(image.protocol)) {
image.replaceWith(image.alt || '');
}
});
return parsed.body.innerHTML;
}
function openDynamicPopup(subitem) {
// Schließe alle offenen Modals
closeAllModals();
const modalTitle = document.getElementById('dynamicModalLabel');
modalTitle.replaceChildren(...iconAndName(subitem));
// Setze den Titel mit Icon, falls vorhanden
const modalTitle = document.getElementById('dynamicModalLabel');
if (subitem.icon && subitem.icon.class) {
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
} else {
modalTitle.innerText = subitem.name;
}
// Setze den Identifier, falls vorhanden
const identifierBox = document.getElementById('dynamicIdentifierBox');
const modalContent = document.getElementById('dynamicModalContent');
if (subitem.identifier) {
@@ -54,19 +21,25 @@ function openDynamicPopup(subitem) {
modalContent.value = '';
}
function toggleBox(boxId, textId, content) {
const box = document.getElementById(boxId);
if (content) {
box.classList.remove('d-none');
document.getElementById(textId).innerHTML = renderMarkdown(content);
} else {
box.classList.add('d-none');
}
// Konfiguriere die Warnbox mit Markdown
const warningBox = document.getElementById('dynamicModalWarning');
if (subitem.warning) {
warningBox.classList.remove('d-none');
document.getElementById('dynamicModalWarningText').innerHTML = marked.parse(subitem.warning);
} else {
warningBox.classList.add('d-none');
}
toggleBox('dynamicModalWarning', 'dynamicModalWarningText', subitem.warning);
toggleBox('dynamicModalInfo', 'dynamicModalInfoText', subitem.info);
// Konfiguriere die Infobox mit Markdown
const infoBox = document.getElementById('dynamicModalInfo');
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');
if (!subitem.url && subitem.description) {
descriptionText.classList.remove('d-none');
@@ -76,78 +49,68 @@ function openDynamicPopup(subitem) {
descriptionText.innerText = '';
}
// Konfiguriere den Link oder die Beschreibung
const linkBox = document.getElementById('dynamicModalLink');
const linkHref = document.getElementById('dynamicModalLinkHref');
if (subitem.url) {
linkBox.classList.remove('d-none');
linkHref.href = subitem.url;
if (!isSafeUrl(subitem.url)) {
linkHref.removeAttribute('href');
}
linkHref.innerText = subitem.description || t("Open Link");
linkHref.classList.remove('iframe');
linkHref.onclick = null;
if (subitem.iframe) {
linkHref.classList.add('iframe');
linkHref.onclick = function(event) {
event.preventDefault();
openIframe(subitem.url);
closeAllModals();
};
}
linkHref.innerText = subitem.description || "Open Link";
} else {
linkBox.classList.add('d-none');
linkHref.href = '#';
}
function populateSection(sectionId, listId, items, onClickHandler) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
list.innerHTML = '';
if (items && items.length > 0) {
section.classList.remove('d-none');
items.forEach(item => {
const listItem = document.createElement('li');
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
const label = document.createElement('span');
label.replaceChildren(...iconAndName(item));
const button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-sm';
button.textContent = t('Open');
listItem.replaceChildren(label, button);
button.addEventListener('click', () => onClickHandler(item));
list.appendChild(listItem);
});
} else {
section.classList.add('d-none');
}
}
populateSection('dynamicAlternativesSection', 'dynamicAlternativesList', subitem.alternatives, openDynamicPopup);
populateSection('dynamicChildrenSection', 'dynamicChildrenList', subitem.children, openDynamicPopup);
// Konfiguriere die Alternativen
const alternativesSection = document.getElementById('dynamicAlternativesSection');
const alternativesList = document.getElementById('dynamicAlternativesList');
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');
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
listItem.innerHTML = `
<span>
<i class="${alt.icon.class}"></i> ${alt.name}
</span>
<button class="btn btn-outline-secondary btn-sm">Open</button>
`;
listItem.querySelector('button').addEventListener('click', () => openDynamicPopup(alt));
alternativesList.appendChild(listItem);
});
} else {
alternativesSection.classList.add('d-none');
}
// Kopierfunktion für den Identifier
const copyButton = document.getElementById('dynamicCopyButton');
copyButton.onclick = () => {
modalContent.select();
navigator.clipboard.writeText(modalContent.value).then(() => {
alert(t('Identifier copied to clipboard!'));
alert('Identifier copied to clipboard!');
});
};
// Modal anzeigen
const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
modal.show();
}
function closeAllModals() {
const modals = document.querySelectorAll('.modal.show');
const modals = document.querySelectorAll('.modal.show'); // Alle offenen Modals finden
modals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
modalInstance.hide(); // Modal ausblenden
}
});
// Entferne die "modal-backdrop"-Elemente
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => backdrop.remove());
// Entferne die Klasse, die den Hintergrund ausgraut
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';

View File

@@ -1,214 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
function getDirectChildByClass(item, className) {
return Array.from(item.children).find(child => child.classList?.contains(className));
}
function getMenu(item) {
return getDirectChildByClass(item, 'dropdown-menu');
}
function getToggle(item) {
return getDirectChildByClass(item, 'dropdown-toggle');
}
function isTopLevelDropdown(item) {
return (
item.classList.contains('nav-item') &&
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
);
}
function chooseDirection(item) {
const rect = item.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceAbove > spaceBelow) {
item.classList.add('dropup');
item.classList.remove('dropdown');
} else {
item.classList.add('dropdown');
item.classList.remove('dropup');
}
}
function ensureDropdownInstances(root = document) {
const scope = root && root.querySelectorAll ? root : document;
scope
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
.forEach(toggle => {
toggle.setAttribute('data-bs-toggle', 'dropdown');
if (!toggle.hasAttribute('aria-expanded')) {
toggle.setAttribute('aria-expanded', 'false');
}
if (window.bootstrap?.Dropdown) {
// Use Popper strategy: 'fixed' so the menu is positioned relative
// to the viewport and escapes ancestors with overflow:hidden
// (e.g. <header> which clips for the fullscreen-collapse animation).
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
new window.bootstrap.Dropdown(toggle, {
popperConfig(defaultBsPopperConfig) {
return { ...defaultBsPopperConfig, strategy: 'fixed' };
},
});
}
});
}
function addMenuEventListeners(items, isTopLevel) {
items.forEach(item => {
let timeout;
function onMouseEnter() {
clearTimeout(timeout);
openMenu(item, isTopLevel, 'hover');
}
function onMouseLeave() {
timeout = setTimeout(() => {
closeMenu(item);
}, 500);
}
// Open on hover
item.addEventListener('mouseenter', onMouseEnter);
// Delayed close on mouse leave
item.addEventListener('mouseleave', onMouseLeave);
// Open and adjust position on click
item.addEventListener('click', (e) => {
const toggle = getToggle(item);
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
if (isTopLevel && !clickedToggle) {
e.stopPropagation();
return;
}
e.stopPropagation(); // Prevents menus from closing when clicking inside
if (isTopLevel) {
e.preventDefault();
if (window.bootstrap?.Dropdown) {
if (item.dataset.openedBy === 'click') {
closeMenu(item);
} else if (getMenu(item)) {
item.dataset.openedBy = 'click';
item.classList.add('open');
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
}
return;
}
}
if (item.classList.contains('open')) {
closeMenu(item);
} else {
openMenu(item, isTopLevel);
}
});
});
}
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
function addAllMenuEventListeners() {
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
addMenuEventListeners(updatedMenuItems, true);
addMenuEventListeners(updatedSubMenuItems, false);
}
ensureDropdownInstances();
addAllMenuEventListeners();
// Global click listener to close menus when clicking outside
document.addEventListener('click', () => {
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
});
function openMenu(item, isTopLevel, openedBy = 'script') {
item.classList.add('open');
const submenu = getMenu(item);
if (!submenu) return;
if (isTopLevel) {
item.dataset.openedBy = openedBy;
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
return;
}
}
submenu.style.display = 'block';
submenu.style.opacity = '1';
submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel);
}
function closeMenu(item) {
item.classList.remove('open');
delete item.dataset.openedBy;
const submenu = getMenu(item);
if (!submenu) return;
if (isTopLevelDropdown(item)) {
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
return;
}
}
submenu.style.display = 'none';
submenu.style.opacity = '0';
submenu.style.visibility = 'hidden';
}
function isSmallScreen() {
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
}
function adjustMenuPosition(submenu, parent, isTopLevel) {
const parentRect = parent.getBoundingClientRect();
const spaceAbove = parentRect.top;
const spaceBelow = window.innerHeight - parentRect.bottom;
const spaceLeft = parentRect.left;
const spaceRight = window.innerWidth - parentRect.right;
submenu.style.top = '';
submenu.style.bottom = '';
submenu.style.left = '';
submenu.style.right = '';
if (isTopLevel) {
if (isSmallScreen() && spaceBelow < spaceAbove) {
// For small screens: Open menu directly above the parent element
submenu.style.top = 'auto';
submenu.style.bottom = `${parentRect.height}px`; // Directly above the parent element
}
// Top-level menu
else if (spaceBelow < spaceAbove) {
submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`;
submenu.style.top = 'auto';
} else {
submenu.style.top = `${parentRect.height}px`;
submenu.style.bottom = 'auto';
}
} else {
// Submenu
const prefersRight = spaceRight >= spaceLeft;
submenu.style.left = prefersRight ? '100%' : 'auto';
submenu.style.right = prefersRight ? 'auto' : '100%';
// Open upwards if there's no space below
if (spaceBelow < spaceAbove) {
submenu.style.bottom = `0`;
submenu.style.top = `auto`;
} else {
submenu.style.top = `0`;
submenu.style.bottom = `${parentRect.height}px`;
}
}
}
});

28
app/static/js/submenus.js Normal file
View File

@@ -0,0 +1,28 @@
document.addEventListener('DOMContentLoaded', () => {
const dropdownSubmenus = document.querySelectorAll('.dropdown-submenu');
dropdownSubmenus.forEach(submenu => {
let timeout;
// Zeige das Submenü beim Hover
submenu.addEventListener('mouseenter', () => {
clearTimeout(timeout);
const menu = submenu.querySelector('.dropdown-menu');
if (menu) {
menu.style.display = 'block';
menu.style.opacity = '1';
}
});
// Verstecke das Submenü nach 0.5 Sekunden
submenu.addEventListener('mouseleave', () => {
const menu = submenu.querySelector('.dropdown-menu');
if (menu) {
timeout = setTimeout(() => {
menu.style.display = 'none';
menu.style.opacity = '0';
}, 500); // 0.5 Sekunden Verzögerung
}
});
});
});

View File

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

View File

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

View File

@@ -1,34 +1,17 @@
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }}
{% elif card.icon.cache or card.icon.external_url %}
<img
src="{{ asset_src(card.icon) }}"
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>
<hr />
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-text">{{ card.text }}</p>
{% if card.url %}
<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 %}
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
{% endif %}
<div class="card-column col-lg-3 col-md-6 col-12">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-text">{{ card.text }}</p>
{% if card.link %}
<a href="{{ card.link }}" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> {{ card.link_text }}</a>
{% else %}
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@@ -1,120 +1,68 @@
{% macro render_icon_and_name(item) %}
<i class="{{ item.icon.class if item.icon is defined and item.icon.class is defined else 'fa-solid fa-link' }}"></i>
{% if item.name is defined %}
{{ item.name }}
{% else %}
Unnamed Item: {{item}}
{% endif %}
{% endmacro %}
<!-- Template for children -->
{% macro render_children(children) %}
{% for child in children %}
{% if child.children %}
<li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
<ul class="dropdown-menu">
{{ render_children(child.children) }}
</ul>
</li>
{% elif child.identifier or child.warning or child.info %}
<li>
<a class="dropdown-item"
onclick='openDynamicPopup({{ child|tojson|safe }})'
data-bs-toggle="tooltip"
title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
</li>
{% else %}
<li>
<a class="dropdown-item {% if child.iframe %}iframe-link{% endif %}"
{% 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>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
<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>
</button>
<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="{{ asset_src(platform.logo) }}"
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">
{% for item in navigation[menu_type].children %}
{% if item.url or item.onclick %}
<li class="nav-item">
<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) }}
</a>
</li>
{% else %}
<!-- Dropdown Menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }}
{% else %}
<p>Missing icon in item: {{ item }}</p>
{% endif %}
</a>
<ul class="dropdown-menu">
{{ render_children(item.children) }}
</ul>
</li>
{% endif %}
{% endfor %}
{% if menu_type == "header" %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdownLanguage" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false" title="{{ t('Language') }}">
<i class="fa-solid fa-language"></i> {{ languages[lang] }}
<!-- Template for Subitems -->
{% macro render_subitems(subitems) %}
{% for subitem in subitems %}
{% if subitem.subitems %}
<li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" href="#" title="{{ subitem.description }}">
<i class="{{ subitem.icon.class }}"></i> {{ subitem.name }}
</a>
<ul class="dropdown-menu language-menu">
{% for code, endonym in languages.items() %}
<li>
<a class="dropdown-item {% if code == lang %}active{% endif %}"
href="{{ url_for('localized_index', lang=code) }}"
hreflang="{{ code }}"
lang="{{ code }}">{{ endonym }}</a>
</li>
{% endfor %}
<ul class="dropdown-menu">
{{ render_subitems(subitem.subitems) }}
</ul>
</li>
{% endif %}
</ul>
{% elif subitem.identifier or subitem.warning or subitem.info %}
<li>
<a class="dropdown-item" onclick='openDynamicPopup({{ subitem|tojson|safe }})' data-bs-toggle="tooltip" title="{{ subitem.description }}">
<i class="{{ subitem.icon.class }}"></i> {{ subitem.name }}
</a>
</li>
{% else %}
<li>
<a class="dropdown-item" href="{{ subitem.url }}" target="{{ subitem.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ subitem.description }}">
{% if subitem.icon is defined and subitem.icon.class is defined %}
<i class="{{ subitem.icon.class }}"></i> {{ subitem.name }}
{% else %}
<p>Missing icon in subitem: {{ subitem }}</p>
{% endif %}
</a>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<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">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %}">
{% for item in navigation[menu_type] %}
{% if item.url %}
<!-- Single Item -->
<li class="nav-item">
<a class="nav-link" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
<i class="{{ item.icon.class }}"></i> {{ item.name }}
</a>
</li>
{% else %}
<!-- Dropdown Menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" data-popper-placement="top" title="{{ item.description }}" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %}
<i class="{{ item.icon.class }}" data-bs-toggle="tooltip"></i> {{ item.name }}
{% else %}
<p>Missing icon in item: {{ item }}</p>
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-{{menu_type}}" aria-labelledby="navbarDropdown{{ loop.index }}">
{{ render_subitems(item.subitems) }}
</ul>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
</nav>
</nav>

View File

@@ -3,9 +3,6 @@
{% block content %}
<div class="row">
{% 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" %}
{% endfor %}
</div>

View File

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

View File

@@ -1,89 +0,0 @@
"""Split asset resolution into distinct fields so the template can
render the right URL.
Strategy
--------
1. **Probe** the source URL with a HEAD request. If it responds with an
``image/*`` content type, embed it directly via ``external_url``
(no download necessary — the browser fetches it itself).
2. **Cache** as a fallback: when the probe fails (404, non-image,
network error, …) try ``cache_manager.cache_file()`` to download
and serve via ``/static/cache/<file>``.
3. **External fallback**: if the download fails too, still expose the
source URL via ``external_url`` so the browser shows at least the
alt-text or broken-image icon instead of an empty ``src``.
Type safety
-----------
``cache`` only ever holds a relative path the static handler can serve;
absolute URLs land in ``external_url`` and are rendered as-is. The
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
both shapes — and the template's ``url_for('static', filename=...)``
wrapper produced broken ``/static/https://…`` URLs when the source
was an absolute URL the container couldn't fetch.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional
import requests
_PROBE_TIMEOUT_SECONDS = 3
def _probe_image_url(url: str) -> bool:
"""Return True if a HEAD request to ``url`` succeeds with an
``image/*`` content type, suggesting the URL is safe to embed
directly as ``<img src>``."""
try:
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
resp.raise_for_status()
except requests.RequestException:
return False
content_type = resp.headers.get("Content-Type", "").lower()
return content_type.split(";", 1)[0].strip().startswith("image/")
def resolve_asset_cache(
asset: dict,
cache_manager,
probe: Callable[[str], bool] = _probe_image_url,
) -> None:
"""Set ``asset['cache']`` (local, served via /static/) or
``asset['external_url']`` (rendered as-is). Never both.
Tries the cheap direct-embed path first (``probe``) before falling
back to downloading via ``cache_manager``. See module docstring."""
source = asset.get("source")
asset["cache"] = None
asset["external_url"] = None
if not source:
return
if probe(source):
asset["external_url"] = source
return
cached = cache_manager.cache_file(source)
if cached:
asset["cache"] = cached
else:
asset["external_url"] = source
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
"""Return the final URL for an asset dict prepared by
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
maps a relative cache path to its served URL (typically a partial
of Flask's ``url_for('static', filename=...)``)."""
if not asset:
return ""
external = asset.get("external_url")
if external:
return external
cached = asset.get("cache")
if cached:
return url_for_static(cached)
return ""

View File

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

@@ -1,42 +0,0 @@
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,154 +1,157 @@
from pprint import pprint
import inspect
class ConfigurationResolver:
"""
A class to resolve `link` entries in a nested configuration structure.
Supports navigation through dictionaries, lists, and `children`.
"""
def __init__(self, config):
"""
Initializes the ConfigurationResolver with a configuration dictionary.
Args:
config (dict): The configuration to resolve links in.
"""
self.config = config
def resolve_links(self):
"""
Resolves all `link` entries in the configuration.
Resolves all `link` entries in the configuration by replacing them with
the referenced configuration entry.
"""
self._recursive_resolve(self.config, self.config)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
index = list_origine.index(old_element)
list_origine[index : index + 1] = new_elements
def _replace_element_in_list(self, list_origine, old_element, new_element):
index = list_origine.index(old_element)
list_origine[index] = new_element
def _recursive_resolve(self, current_config, root_config):
def _recursive_resolve(self, current_config, root_config, path=""):
"""
Recursively resolves `link` entries in the configuration.
Recursively traverses the configuration to resolve all `link` entries.
Args:
current_config (dict or list): The current section of the configuration being processed.
root_config (dict): The root of the configuration to resolve links against.
path (str): The current path in the configuration for debugging purposes.
"""
if isinstance(current_config, dict):
self._debug(current_config,path,inspect.currentframe().f_lineno)
# Traverse all key-value pairs in the dictionary
for key, value in list(current_config.items()):
if key == "children":
if value is None or not isinstance(value, list):
raise ValueError(
"Expected 'children' to be a list, but got "
f"{type(value).__name__} instead."
)
for item in value:
if "link" in item:
loaded_link = self._find_entry(
root_config,
self._mapped_key(item["link"]),
False,
)
if isinstance(loaded_link, list):
self._replace_in_list_by_list(value, item, loaded_link)
else:
self._replace_element_in_list(value, item, loaded_link)
else:
self._recursive_resolve(value, root_config)
if key == "subitems" and isinstance(value, list):
pass
#self._debug(value,path,inspect.currentframe().f_lineno)
#for index, item in enumerate(current_config[key]):
# #self._debug(value,path,inspect.currentframe().f_lineno)
# if "link" in item:
# self._debug(value,path,inspect.currentframe().f_lineno)
# self._recursive_resolve(item, root_config, path=f"{path}[{index}]")
elif key == "link":
# Found a `link` entry, attempt to resolve it
try:
loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
if isinstance(loaded, list) and len(loaded) > 2:
loaded = self._find_entry(
root_config, self._mapped_key(value), False
target = self._find_entry(root_config, value.lower().replace(" ", "_"))
if isinstance(target, dict):
self._debug(value,path,inspect.currentframe().f_lineno)
# Replace the current dictionary with the resolved dictionary
current_config.clear()
current_config.update(target)
elif isinstance(target, str):
self._debug(value,path,inspect.currentframe().f_lineno)
# Replace the `link` entry with the resolved string
current_config[key] = target
else:
raise ValueError(
f"Expected a dictionary or string for link '{value}', got {type(target)}"
)
current_config.clear()
current_config.update(loaded)
except Exception as e:
except KeyError as e:
# Handle unresolved links
raise ValueError(
f"Error resolving link '{value}': {str(e)}. "
f"Current part: {key}, Current config: {current_config}"
+ (
f", Loaded: {loaded}"
if "loaded" in locals() or "loaded" in globals()
else ""
)
f"Key error while resolving link '{value}': {str(e)}. "
f"Current path: {path}, Current config: {current_config}"
)
else:
self._recursive_resolve(value, root_config)
self._debug(value,path,inspect.currentframe().f_lineno)
# Recursively resolve non-`link` entries
self._recursive_resolve(value, root_config, path=f"{path}.{key}")
elif isinstance(current_config, list):
for item in current_config:
self._recursive_resolve(item, root_config)
# Traverse all items in the list
for index, item in enumerate(current_config):
self._recursive_resolve(item, root_config, path=f"{path}[{index}]")
def _debug(self, value, path, line, condition="accounts"):
if condition in path:
print("LINE:" + str(line))
print("PATH:" + path)
print("VALUE:")
pprint(value)
def _get_children(self, current):
if isinstance(current, dict) and (
"children" in current and current["children"]
):
current = current["children"]
return current
def _mapped_key(self, name):
return name.replace(" ", "").lower()
def _find_by_name(self, current, part):
return next(
(
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):
"""
Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation.
Finds an entry in the configuration by navigating a dot-separated path.
Args:
config (dict or list): The configuration to search within.
path (str): The dot-separated path to the desired entry.
Returns:
dict or str: The resolved entry.
Raises:
KeyError: If the path cannot be resolved.
ValueError: If the resolved entry is not of the expected type.
"""
parts = path.split(".")
parts = path.split('.') # Split the path into segments
current = config
for part in parts:
if isinstance(current, list):
if part != "children":
found = self._find_by_name(current, part)
if found:
current = found
print(
f"Matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
else:
raise ValueError(
f"No matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
elif isinstance(current, dict):
key = next((k for k in current if self._mapped_key(k) == part), 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)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
else:
current = current[key]
for part in parts:
part = part.replace(" ", "_") # Normalize part name
if isinstance(current, list):
# Search for a matching entry in a list
found = next(
(
item
for item in current
if isinstance(item, dict) and item.get("name", "").lower().replace(" ", "_") == part
),
None
)
if not found:
raise KeyError(
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current list: {current}"
)
current = found
elif isinstance(current, dict):
# Search for a key match in a dictionary
key = next((k for k in current if k.lower().replace(" ", "_") == part), None)
if key is None:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current dictionary: {current}"
)
current = current[key]
else:
# Invalid path segment
raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. "
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
)
if children:
current = self._get_children(current)
# Stop navigating into `subitems` unless explicitly required by the path
if isinstance(current, dict) and "subitems" in current and isinstance(current["subitems"], list) and part != "subitems":
break # Stop navigation if `subitems` is not explicitly in the path
# Ensure the resolved target is a dictionary or string
if not isinstance(current, (dict, str)):
raise ValueError(
f"Expected a dictionary or string for path '{path}', got {type(current)}. Current value: {current}"
)
return current
def get_config(self):
"""
Returns the resolved configuration.
Returns the fully resolved configuration.
Returns:
dict: The resolved configuration.
"""
return self.config

View File

@@ -1,134 +0,0 @@
"""Language negotiation and translation of the resolved configuration tree.
Translation is catalogue-driven: a string is replaced only when the target
language's catalogue holds an entry for the exact English source string.
Anything unknown falls through to English, so a partially filled catalogue
degrades instead of breaking.
"""
import logging
from pathlib import Path
import yaml
try:
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.languages import LANGUAGES, RTL_LANGUAGES
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
UI_DIR = I18N_DIR / "ui"
CONTENT_DIR = I18N_DIR / "content"
SOURCE_LANGUAGE = "en"
TRANSLATABLE_KEYS = frozenset(
{"description", "info", "name", "subtitel", "text", "title", "warning"}
)
UI_STRINGS = (
"Alternatives",
"Close",
"Copy",
"Identifier copied to clipboard!",
"Imprint",
"Information",
"Language",
"Open",
"Open Link",
"Options",
"Warning",
)
_catalogs: dict[str, dict[str, str]] = {}
def direction(code):
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
return "rtl" if code in RTL_LANGUAGES else "ltr"
def read_catalog(path):
"""Return the catalogue at ``path``, or an empty one if it is unusable.
Catalogues are hand-edited and machine-written, so a stray character must
degrade that language to English rather than take every page down with a
parse error. Non-string entries are dropped for the same reason: they would
otherwise reach the templates and render as ``42`` or ``null``.
"""
if not path.exists():
return {}
try:
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, yaml.YAMLError):
logging.warning("Ignoring unreadable translation catalogue: %s", path)
return {}
if not isinstance(loaded, dict):
logging.warning(
"Ignoring translation catalogue that is not a mapping: %s", path
)
return {}
return {
key: value
for key, value in loaded.items()
if isinstance(key, str) and isinstance(value, str)
}
def clear_catalogs():
"""Drop the memoized catalogues so edited files are picked up."""
_catalogs.clear()
def catalog(code):
"""Return the merged UI and content catalogue for ``code``."""
if code not in _catalogs:
_catalogs[code] = {
**read_catalog(UI_DIR / f"{code}.yaml"),
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
}
return _catalogs[code]
def negotiate(accepted, default=SOURCE_LANGUAGE):
"""Pick the best supported language from ``Accept-Language`` pairs.
Args:
accepted: iterable of ``(tag, quality)`` as produced by
``flask.request.accept_languages``.
default: language returned when no tag is supported.
Werkzeug's own ``best_match`` returns an exact match before it considers
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
on the primary subtag up front avoids that.
"""
best, best_quality = default, 0.0
for tag, quality in accepted:
code = tag.replace("_", "-").split("-")[0].lower()
if code in LANGUAGES and quality > best_quality:
best, best_quality = code, quality
return best
def translate_tree(node, code, key=None):
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
Args:
node: the resolved configuration tree, or any subtree of it.
code: target language code.
key: the mapping key ``node`` was reached through.
"""
if isinstance(node, dict):
return {name: translate_tree(value, code, name) for name, value in node.items()}
if isinstance(node, list):
return [translate_tree(item, code, key) for item in node]
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
return catalog(code).get(node, node)
return node
def ui_strings(code):
"""Return the interface strings for ``code``, keyed by their English source."""
entries = catalog(code)
return {source: entries.get(source, source) for source in UI_STRINGS}

View File

@@ -1,196 +0,0 @@
"""ISO 639-1 languages, their display names and writing direction.
Generated by utils/generate_languages.py — edit that script, not this file.
Display names are CLDR endonyms where CLDR covers the language and the English
ISO 639-1 name for the 25 codes it does not.
"""
LANGUAGES = {
"en": "English",
"aa": "Qafar",
"ab": "Аԥсшәа",
"ae": "Avestan",
"af": "Afrikaans",
"ak": "Akan",
"am": "አማርኛ",
"an": "aragonés",
"ar": "العربية",
"as": "অসমীয়া",
"av": "Avaric",
"ay": "Aymara",
"az": "azərbaycan",
"ba": "башҡорт теле",
"be": "беларуская",
"bg": "български",
"bi": "Bislama",
"bm": "bamanakan",
"bn": "বাংলা",
"bo": "བོད་སྐད་",
"br": "brezhoneg",
"bs": "bosanski",
"ca": "català",
"ce": "нохчийн",
"ch": "Chamorro",
"co": "corsu",
"cr": "Cree",
"cs": "čeština",
"cu": "церковнослове́нскїй",
"cv": "чӑваш",
"cy": "Cymraeg",
"da": "dansk",
"de": "Deutsch",
"dv": "ދިވެހިބަސް",
"dz": "རྫོང་ཁ",
"ee": "eʋegbe",
"el": "Ελληνικά",
"eo": "Esperanto",
"es": "español",
"et": "eesti",
"eu": "euskara",
"fa": "فارسی",
"ff": "Pulaar",
"fi": "suomi",
"fj": "Fijian",
"fo": "føroyskt",
"fr": "français",
"fy": "Frysk",
"ga": "Gaeilge",
"gd": "Gàidhlig",
"gl": "galego",
"gn": "avañe",
"gu": "ગુજરાતી",
"gv": "Gaelg",
"ha": "Hausa",
"he": "עברית",
"hi": "हिन्दी",
"ho": "Hiri Motu",
"hr": "hrvatski",
"ht": "Kreyòl Ayisyen",
"hu": "magyar",
"hy": "հայերեն",
"hz": "Herero",
"ia": "interlingua",
"id": "Indonesia",
"ie": "Interlingue",
"ig": "Igbo",
"ii": "ꆈꌠꉙ",
"ik": "Inupiaq",
"io": "Ido",
"is": "íslenska",
"it": "italiano",
"iu": "ᐃᓄᒃᑎᑐᑦ",
"ja": "日本語",
"jv": "Jawa",
"ka": "ქართული",
"kg": "Kongo",
"ki": "Gikuyu",
"kj": "Kuanyama",
"kk": "қазақ тілі",
"kl": "kalaallisut",
"km": "ខ្មែរ",
"kn": "ಕನ್ನಡ",
"ko": "한국어",
"kr": "Kanuri",
"ks": "کٲشُر",
"ku": "kurdî (kurmancî)",
"kv": "Komi",
"kw": "kernewek",
"ky": "кыргызча",
"la": "Latina",
"lb": "Lëtzebuergesch",
"lg": "Luganda",
"li": "Limburgan",
"ln": "lingála",
"lo": "ລາວ",
"lt": "lietuvių",
"lu": "Tshiluba",
"lv": "latviešu",
"mg": "Malagasy",
"mh": "Marshallese",
"mi": "Māori",
"mk": "македонски",
"ml": "മലയാളം",
"mn": "монгол",
"mr": "मराठी",
"ms": "Melayu",
"mt": "Malti",
"my": "မြန်မာ",
"na": "Nauru",
"nb": "norsk bokmål",
"nd": "isiNdebele",
"ne": "नेपाली",
"ng": "Ndonga",
"nl": "Nederlands",
"nn": "norsk nynorsk",
"no": "norsk",
"nr": "isiNdebele",
"nv": "Diné Bizaad",
"ny": "Nyanja",
"oc": "occitan",
"oj": "Ojibwa",
"om": "Oromoo",
"or": "ଓଡ଼ିଆ",
"os": "ирон",
"pa": "ਪੰਜਾਬੀ",
"pi": "Pali",
"pl": "polski",
"ps": "پښتو",
"pt": "português",
"qu": "Runasimi",
"rm": "rumantsch",
"rn": "Ikirundi",
"ro": "română",
"ru": "русский",
"rw": "Ikinyarwanda",
"sa": "संस्कृत भाषा",
"sc": "sardu",
"sd": "سنڌي",
"se": "davvisámegiella",
"sg": "Sängö",
"sh": "Serbo-Croatian",
"si": "සිංහල",
"sk": "slovenčina",
"sl": "slovenščina",
"sm": "Samoan",
"sn": "chiShona",
"so": "Soomaali",
"sq": "shqip",
"sr": "српски",
"ss": "siSwati",
"st": "Sesotho",
"su": "Basa Sunda",
"sv": "svenska",
"sw": "Kiswahili",
"ta": "தமிழ்",
"te": "తెలుగు",
"tg": "тоҷикӣ",
"th": "ไทย",
"ti": "ትግርኛ",
"tk": "türkmen dili",
"tl": "Tagalog",
"tn": "Setswana",
"to": "lea fakatonga",
"tr": "Türkçe",
"ts": "Xitsonga",
"tt": "татар",
"tw": "Twi",
"ty": "Tahitian",
"ug": "ئۇيغۇرچە",
"uk": "українська",
"ur": "اردو",
"uz": "ozbek",
"ve": "Tshivenḓa",
"vi": "Tiếng Việt",
"vo": "Volapük",
"wa": "walon",
"wo": "Wolof",
"xh": "IsiXhosa",
"yi": "ייִדיש",
"yo": "Èdè Yorùbá",
"za": "Vahcuengh",
"zh": "中文",
"zu": "isiZulu",
}
RTL_LANGUAGES = frozenset({"ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1024 KiB

View File

@@ -1,26 +0,0 @@
services:
portfolio:
# SPOT: IMAGE_NAME comes from .env (see env.example). No default —
# an empty tag would have compose fall back to `portfolio-portfolio`
# silently, which `make run-dev` would then fail to find.
image: ${IMAGE_NAME:?IMAGE_NAME must be set in .env (see env.example)}
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
ports:
- "${PORT:?PORT must be set in .env (see env.example)}:${PORT:?PORT must be set in .env (see env.example)}"
env_file:
- .env
volumes:
- ./app:/app
- node_modules:/app/node_modules
- vendor:/app/static/vendor
# Run `npm install` on every container start so the named volumes
# reflect the current package.json (postinstall regenerates vendor/).
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
restart: unless-stopped
volumes:
node_modules:
vendor:

View File

@@ -1,13 +0,0 @@
PORT=5001
FLASK_ENV=production
# Single source of truth for the Docker image tag — read by both the
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
# build path produces and consumes the same tag.
IMAGE_NAME=portfolio
# Comma-separated public hostnames. Leave empty and the app reflects whatever
# Host header arrives into its canonical, hreflang and redirect URLs.
TRUSTED_HOSTS=
# LibreTranslate instance used by `make i18n` to fill app/i18n/content/.
# Only needed when generating translations, never at runtime.
LIBRETRANSLATE_URL=http://localhost:5002
LIBRETRANSLATE_API_KEY=

79
main.py
View File

@@ -1,79 +0,0 @@
#!/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()

View File

@@ -1,51 +0,0 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "2.1.0"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
# ignores it, which would silently disable the host-forgery check.
"flask>=3.1",
"pyyaml",
"requests",
]
[project.optional-dependencies]
dev = [
"babel",
"bandit",
"pip-audit",
"pycountry",
"ruff",
"yamllint",
]
[tool.setuptools]
py-modules = ["main"]
[tool.setuptools.packages.find]
include = ["app", "app.*"]
[tool.setuptools.package-data]
app = [
"config.sample.yaml",
"i18n/ui/*.yaml",
"i18n/content/*.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"]

View File

@@ -1,73 +0,0 @@
#!/bin/sh
# Run the Cypress suite against a Flask app started by this script.
#
# Server and browser share one shell, so this works where act cannot reach the
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
# VS Code exports it to child processes, which makes Cypress' bundled Electron
# behave as Node and reject its own launch flags.
#
# Env:
# PORT required, the port Flask binds and Cypress targets
# PYTHON interpreter to run app.py with (default: python3)
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
set -u
PORT="${PORT:?PORT must be set (see env.example)}"
PYTHON="${PYTHON:-python3}"
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
FLASK_HOST=127.0.0.1
export PORT FLASK_HOST
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
BASE_URL="http://127.0.0.1:$PORT/"
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
# this script probes. Pin all three to the same origin.
export CYPRESS_baseUrl="$BASE_URL"
# A foreign listener would be tested instead of the working tree. The predicate
# is "something answers", deliberately without curl's -f: a foreign server that
# is still returning 5xx right now would otherwise pass the guard and then be
# picked up by the readiness probe once it recovers. --noproxy is what keeps
# that same looser predicate from matching an HTTP proxy instead of the port.
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: something already serves port $PORT — Cypress would test that"
echo " instead of your working tree. Stop it first ('make down' for"
echo " the container, otherwise a stray 'python app.py')."
exit 1
fi
cd "$APP_DIR" || exit 1
$PYTHON app.py > "$E2E_LOG" 2>&1 &
flask_pid=$!
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
attempt=0
while [ "$attempt" -lt 120 ]; do
# Liveness first: a server answering while ours is dead means we are about
# to hand Cypress somebody else's app.
if ! kill -0 "$flask_pid" 2>/dev/null; then
echo "ERROR: Flask exited during startup"
cat "$E2E_LOG"
exit 1
fi
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: app never became ready"
cat "$E2E_LOG"
exit 1
fi
env -u ELECTRON_RUN_AS_NODE npx cypress run
status=$?
kill "$flask_pid" 2>/dev/null || true
exit "$status"

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1,258 +0,0 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import requests
REPO_ROOT = Path(__file__).resolve().parents[2]
app_module = None
flask_app = None
i18n = None
_origin = None
_workdir = None
def setUpModule():
"""Import the app with a disposable configuration as the working directory.
``app.app`` reads ``config.yaml`` relative to the process working directory
at import time, so the chdir has to happen before the import.
"""
global app_module, flask_app, i18n, _origin, _workdir
_origin = os.getcwd()
_workdir = tempfile.mkdtemp(prefix="portfolio-routes-")
shutil.copy(
REPO_ROOT / "app" / "config.sample.yaml", Path(_workdir) / "config.yaml"
)
os.chdir(_workdir)
from app import app as imported_module
from app.utils import i18n as imported_i18n
app_module = imported_module
flask_app = imported_module.app
i18n = imported_i18n
flask_app.config["NASA_API_KEY"] = None
def tearDownModule():
os.chdir(_origin)
shutil.rmtree(_workdir, ignore_errors=True)
class AppRouteMixin:
"""Shared setup. A mixin rather than a TestCase subclass, so that every
test class below still names ``unittest.TestCase`` as a direct base — the
lint guardrail in tests/lint/ does not resolve inherited aliases."""
def setUp(self):
self.client = flask_app.test_client()
self.addCleanup(i18n.clear_catalogs)
self.addCleanup(flask_app.config["TRANSLATED_CONFIG"].clear)
class TestRouting(AppRouteMixin, unittest.TestCase):
def test_unrelated_single_segment_paths_are_not_redirected(self):
for path in ("/robots.txt", "/favicon.ico", "/sitemap.xml"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 404)
def test_language_path_redirects_to_the_canonical_trailing_slash(self):
response = self.client.get("/de")
self.assertEqual(response.status_code, 308)
self.assertTrue(response.headers["Location"].endswith("/de/"))
def test_supported_language_renders(self):
response = self.client.get("/de/")
self.assertEqual(response.status_code, 200)
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_unsupported_language_is_not_found(self):
self.assertEqual(self.client.get("/xx/").status_code, 404)
class TestNegotiation(AppRouteMixin, unittest.TestCase):
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE,en;q=0.8"})
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_negotiated_route_declares_that_it_varies(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE"})
self.assertEqual(response.headers.get("Vary"), "Accept-Language")
class TestEscaping(AppRouteMixin, unittest.TestCase):
def test_catalog_content_is_html_escaped(self):
i18n._catalogs["de"] = {"Copy": "<script>alert('ui')</script>"}
body = self.client.get("/de/").get_data(as_text=True)
self.assertNotIn("<script>alert('ui')</script>", body)
self.assertIn("&lt;script&gt;alert(&#39;ui&#39;)", body)
def test_configuration_content_is_html_escaped(self):
i18n._catalogs["de"] = {"Imprint": "<script>alert('config')</script>"}
body = self.client.get("/de/").get_data(as_text=True)
self.assertNotIn("<script>alert('config')</script>", body)
self.assertIn("&lt;script&gt;alert(&#39;config&#39;)", body)
class TestApodBackground(AppRouteMixin, unittest.TestCase):
def setUp(self):
super().setUp()
self.addCleanup(flask_app.config.__setitem__, "NASA_API_KEY", None)
flask_app.config["NASA_API_KEY"] = "key"
def test_no_request_is_made_without_a_key(self):
flask_app.config["NASA_API_KEY"] = None
with patch("app.app.requests.get") as get:
self.assertIsNone(app_module.apod_background())
get.assert_not_called()
def test_a_transport_failure_costs_the_background_not_the_page(self):
with patch(
"app.app.requests.get", side_effect=requests.ConnectionError("down")
):
self.assertIsNone(app_module.apod_background())
self.assertEqual(self.client.get("/en/").status_code, 200)
def test_an_error_response_costs_the_background_not_the_page(self):
refusal = Mock(ok=False)
refusal.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
with patch("app.app.requests.get", return_value=refusal):
self.assertIsNone(app_module.apod_background())
def test_a_video_of_the_day_is_not_used_as_a_background(self):
answer = Mock(ok=True)
answer.json.return_value = {"media_type": "video", "url": "https://v.test/x"}
with patch("app.app.requests.get", return_value=answer):
self.assertIsNone(app_module.apod_background())
def test_an_image_of_the_day_is_used(self):
answer = Mock(ok=True)
answer.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
with patch("app.app.requests.get", return_value=answer):
self.assertEqual(app_module.apod_background(), "https://i.test/x")
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
def test_only_the_forwarded_scheme_is_trusted(self):
proxy = flask_app.wsgi_app
self.assertEqual(proxy.x_proto, 1)
self.assertEqual(
(proxy.x_for, proxy.x_host, proxy.x_port, proxy.x_prefix), (0, 0, 0, 0)
)
def test_forwarded_scheme_reaches_the_canonical_and_alternates(self):
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Proto": "https"},
).get_data(as_text=True)
self.assertIn(
'<link rel="canonical" href="https://portfolio.example.org/en/">', body
)
self.assertIn('hreflang="ja" href="https://portfolio.example.org/ja/"', body)
self.assertNotIn("http://portfolio.example.org", body)
def test_trusted_hosts_are_parsed_from_a_comma_separated_list(self):
self.assertEqual(
app_module.trusted_hosts("a.test, b.test ,"), ["a.test", "b.test"]
)
def test_an_unset_trusted_hosts_value_disables_the_check(self):
self.assertIsNone(app_module.trusted_hosts(""))
self.assertIsNone(app_module.trusted_hosts(" , "))
def test_a_forged_host_is_rejected_once_trusted_hosts_are_named(self):
self.addCleanup(flask_app.config.__setitem__, "TRUSTED_HOSTS", None)
flask_app.config["TRUSTED_HOSTS"] = ["portfolio.example.org"]
forged = self.client.get("/de", headers={"Host": "evil.test"})
honest = self.client.get("/de/", headers={"Host": "portfolio.example.org"})
self.assertEqual(forged.status_code, 400)
self.assertEqual(honest.status_code, 200)
def test_forwarded_host_is_not_trusted(self):
response = self.client.get(
"/de",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
)
self.assertNotIn("evil.test", response.headers["Location"])
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
).get_data(as_text=True)
self.assertNotIn("evil.test", body)
class TestConfigurationReload(AppRouteMixin, unittest.TestCase):
def test_reloading_the_configuration_drops_the_catalog_memo(self):
i18n.catalog("de")
self.assertIn("de", i18n._catalogs)
app_module.load_config(flask_app)
self.assertEqual(i18n._catalogs, {})
def test_reloading_the_configuration_drops_the_translation_memo(self):
self.client.get("/de/")
self.assertIn("de", flask_app.config["TRANSLATED_CONFIG"])
app_module.load_config(flask_app)
self.assertEqual(flask_app.config["TRANSLATED_CONFIG"], {})
class TestTrustedHostsWiring(unittest.TestCase):
def test_the_environment_reaches_the_flask_configuration(self):
script = (
"import json, sys;"
f"sys.path.insert(0, {str(REPO_ROOT)!r});"
"from app.app import app;"
"print('TRUSTED=' + json.dumps(app.config['TRUSTED_HOSTS']))"
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=_workdir,
env={**os.environ, "TRUSTED_HOSTS": "a.test, b.test"},
capture_output=True,
text=True,
check=True,
)
reported = [
line for line in result.stdout.splitlines() if line.startswith("TRUSTED=")
]
self.assertEqual(
json.loads(reported[-1][len("TRUSTED=") :]), ["a.test", "b.test"]
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,84 +0,0 @@
"""Live network verification of the probe-first asset resolution path.
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
branch with mocks. This integration test exercises the real
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
green run proves the production resolver behaviour end-to-end:
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
asset host, so ``external_url`` is populated directly and **no**
download is performed (the cache directory stays empty).
The download-fallback branch (probe fails → ``cache_manager.cache_file``
serves the asset locally) is intentionally not exercised against a live
endpoint here — it is covered by mocked unit tests because every
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
when GET works), which would make this guard test non-deterministic.
"""
from __future__ import annotations
import os
import unittest
from tempfile import TemporaryDirectory
from urllib.parse import urlparse
import requests
from app.utils.asset_resolver import resolve_asset_cache
from app.utils.cache_manager import CacheManager
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
def _reachable(url: str) -> bool:
try:
return requests.head(url, allow_redirects=True, timeout=5).ok
except requests.RequestException:
return False
@unittest.skipIf(
_SKIP_LIVE_NETWORK,
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
)
class TestAssetResolverLive(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not _reachable(INFINITO_LOGO_URL):
raise unittest.SkipTest(
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
)
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
with TemporaryDirectory() as tmp:
cache_dir = os.path.join(tmp, "cache")
cache_manager = CacheManager(cache_dir)
asset = {"source": INFINITO_LOGO_URL}
resolve_asset_cache(asset, cache_manager)
self.assertIsNone(
asset["cache"],
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
)
self.assertEqual(
asset["external_url"],
INFINITO_LOGO_URL,
"Probe-success URL must land in external_url so the template "
"embeds it directly instead of routing through /static/.",
)
self.assertEqual(
os.listdir(cache_dir),
[],
"Cache directory must stay empty when probe succeeds — "
"downloading would waste bandwidth.",
)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More