9 Commits

77 changed files with 926 additions and 4800 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,77 +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-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

12
.gitignore vendored
View File

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

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,23 +0,0 @@
## [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 # Basis-Image für Python
FROM python:slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/build
COPY pyproject.toml README.md main.py ./
COPY app ./app
RUN python -m pip install --no-cache-dir .
# Arbeitsverzeichnis festlegen
WORKDIR /app WORKDIR /app
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"] 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

169
Makefile
View File

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

169
README.md
View File

@@ -1,158 +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.
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces. ## Administrate Docker
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo) ### Stop and Destroy
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site) ```bash
docker stop landingpage
--- docker rm landingpage
```
## ✨ Key Features
- **Dynamic Navigation**
Create dropdowns & nested menus with ease.
- **Customizable Cards**
Highlight skills, projects, or services—with icons, titles, and links.
- **Smart Cache Management**
Auto-cache assets for lightning-fast loading.
- **Responsive Design**
Built on Bootstrap; looks great on desktop, tablet & mobile.
- **YAML-Driven**
All content & structure defined in a simple `config.yaml`.
- **CLI Control**
Manage Docker containers via the `portfolio` command.
---
## 🌐 Quick Access
- **Local Preview:**
[http://127.0.0.1:5000](http://127.0.0.1:5000)
---
## 🏁 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
### Build
```bash ```bash
pkgmgr install portui docker build -t application-landingpage .
``` ```
Once installed, the `portui` CLI is available system-wide. ### Run
---
## 🖥️ CLI Commands
#### Run Development Environment
```bash ```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 #### Run Production Environment
* `up`Start containers (with build) ```bash
* `down`Stop & remove containers docker run -d -p 5000:5000 --name landingpage application-landingpage
* `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. ### Debug
* **`link`** references other YAML paths to avoid duplication. ```bash
docker logs -f landingpage
--- ```
## Author
## 🚢 Production Deployment This software was created from [Kevin Veen-Birkenbach](https://www.veen.world/) with the help of [ChatGPT]()
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
---
## 📜 License
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
---
## ✍️ Author
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
Enjoy building your portfolio! 🌟

2
app/.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,683 +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://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,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,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,16 +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": {
"cypress": "^14.5.1"
},
"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,71 +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', '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 { a {
text-decoration: none; text-decoration: none;
color: #000000; color: #000000;
} }
/* Header styles */
.header img { .header img {
float: right; float: right;
width: 100px; width: 100px;
@@ -16,197 +11,129 @@ a {
.header h1 { .header h1 {
position: relative; position: relative;
} }
/* Equal-height container using flexbox */
.equal-height { .equal-height {
display: flex; display: flex;
flex: 1; 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 { .card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; /* Center content horizontally */ align-items: center; /* Zentriert die Inhalte horizontal */
text-align: center; /* Center text alignment */ text-align: center; /* Zentriert den Text */
} }
.card-icon { .card-icon {
display: flex; display: flex;
justify-content: center; /* Center the icon horizontally */ justify-content: center; /* Zentriert das Icon horizontal */
} }
.card-text, .card-text,
.card ul { .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-top: 12px;
padding-bottom: 12px; padding-bottom: 12px;
} }
.card .stretched-link { h3.footer-title{
font-size: 0.7em;
}
h3.card-title {
font-size: 1.3em; font-size: 1.3em;
} }
/* Footer styles */
.footer { .footer {
margin-top: 12px;
text-align: center; text-align: center;
font-size: 0.7em; font-size: 0.7em;
} }
.footer p, .footer p, h3{
.footer h3 { margin: 0px;
margin: 0; padding: 0px;
padding: 0;
} }
h3.footer-title { .dropdown-menu {
font-size: 1.3em; position: absolute !important;
} }
.card-img-top i, .card-img-top svg{ .dropdown-menu-footer {
font-size: 100px; position: absolute !important;
fill: currentColor; top: auto !important;
width: 100px; bottom: 100%; /* Positioniert das Menü über dem Auslöser */
height: auto; transform: translateY(-10px); /* Optional: Sanfter Abstand */
} }
div#navbarNavheader li.nav-item { .dropdown-submenu {
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;
}
main, footer, header, nav {
position: relative; position: relative;
box-shadow: list-style: none;
/* Inner shadow */
inset 10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Left inner shadow */
inset -10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right inner shadow */
/* Outer shadow */
10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right outer shadow */
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
overflow: visible;
} }
header{ .navbar, .card {
padding: 12px; 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, .navbar-nav {
footer { padding: 0;
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: 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 */ .dropdown-submenu > .dropdown-menu {
body::before { position: absolute;
content: ""; top: 0;
position: fixed; left: 100%; /* Positioniert das Submenü rechts vom Hauptmenü */
inset: 0; margin-top: -1px;
pointer-events: none;
z-index: -1;
} }
iframe{ .dropdown-menu.collapse {
margin-bottom: -10px; display: none;
} }
.container-fluid { .dropdown-menu.collapse.show {
max-width: 100% !important; display: block;
} }
:root { /* Standardmäßig sind die Submenüs ausgeblendet */
--anim-duration: 3s; /* Basis-Dauer */ .dropdown-submenu .dropdown-menu {
} display: none;
.container,
.container-fluid {
transition:
max-width var(--anim-duration) ease-in-out,
padding-left var(--anim-duration) ease-in-out,
padding-right var(--anim-duration) ease-in-out;
}
#navbar_logo {
opacity: 0; opacity: 0;
max-width: 0; transition: opacity 0.3s ease-in-out;
overflow: hidden; position: absolute;
transition: opacity var(--anim-duration) ease-in-out, left: 100%;
max-width var(--anim-duration) ease-in-out; top: 0;
} }
#navbar_logo.visible { /* Beim Hover auf das Submenü-Element wird das Menü angezeigt */
opacity: 1 !important; .dropdown-submenu:hover > .dropdown-menu {
max-width: 300px; display: block;
opacity: 1;
z-index: 1050;
} }
/* Um sicherzustellen, dass es nicht sofort verschwindet */
/* 1. Make sure headers and footers can collapse */ .dropdown-submenu:hover > .dropdown-menu:hover {
header, display: block;
footer { opacity: 1;
/* choose a max-height thats >= your tallest header/footer */
max-height: 200px;
padding: 1rem;
transition:
max-height var(--anim-duration) ease-in-out,
padding var(--anim-duration) ease-in-out;
} }
/* 2. In fullscreen mode, collapse them. 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,189 +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) {
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,5 +1,8 @@
function openDynamicPopup(subitem) { function openDynamicPopup(subitem) {
// Schließe alle offenen Modals
closeAllModals(); closeAllModals();
// Setze den Titel mit Icon, falls vorhanden
const modalTitle = document.getElementById('dynamicModalLabel'); const modalTitle = document.getElementById('dynamicModalLabel');
if (subitem.icon && subitem.icon.class) { if (subitem.icon && subitem.icon.class) {
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`; modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
@@ -7,6 +10,7 @@ function openDynamicPopup(subitem) {
modalTitle.innerText = subitem.name; modalTitle.innerText = subitem.name;
} }
// Setze den Identifier, falls vorhanden
const identifierBox = document.getElementById('dynamicIdentifierBox'); const identifierBox = document.getElementById('dynamicIdentifierBox');
const modalContent = document.getElementById('dynamicModalContent'); const modalContent = document.getElementById('dynamicModalContent');
if (subitem.identifier) { if (subitem.identifier) {
@@ -17,19 +21,25 @@ function openDynamicPopup(subitem) {
modalContent.value = ''; modalContent.value = '';
} }
function toggleBox(boxId, textId, content) { // Konfiguriere die Warnbox mit Markdown
const box = document.getElementById(boxId); const warningBox = document.getElementById('dynamicModalWarning');
if (content) { if (subitem.warning) {
box.classList.remove('d-none'); warningBox.classList.remove('d-none');
document.getElementById(textId).innerHTML = marked.parse(content); document.getElementById('dynamicModalWarningText').innerHTML = marked.parse(subitem.warning);
} else { } else {
box.classList.add('d-none'); 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'); const descriptionText = document.getElementById('dynamicDescriptionText');
if (!subitem.url && subitem.description) { if (!subitem.url && subitem.description) {
descriptionText.classList.remove('d-none'); descriptionText.classList.remove('d-none');
@@ -39,53 +49,41 @@ function openDynamicPopup(subitem) {
descriptionText.innerText = ''; descriptionText.innerText = '';
} }
// Konfiguriere den Link oder die Beschreibung
const linkBox = document.getElementById('dynamicModalLink'); const linkBox = document.getElementById('dynamicModalLink');
const linkHref = document.getElementById('dynamicModalLinkHref'); const linkHref = document.getElementById('dynamicModalLinkHref');
if (subitem.url) { if (subitem.url) {
linkBox.classList.remove('d-none'); linkBox.classList.remove('d-none');
linkHref.href = subitem.url; linkHref.href = subitem.url;
linkHref.innerText = subitem.description || "Open Link"; linkHref.innerText = subitem.description || "Open Link";
if (subitem.iframe) {
linkHref.classList.add('iframe');
// Attach an event listener that prevents the default behavior and
// opens the URL in an iframe when clicked.
linkHref.addEventListener('click', function(event) {
event.preventDefault();
openIframe(subitem.url);
closeAllModals()
});
}
} else { } else {
linkBox.classList.add('d-none'); linkBox.classList.add('d-none');
linkHref.href = '#'; linkHref.href = '#';
} }
function populateSection(sectionId, listId, items, onClickHandler) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
list.innerHTML = '';
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');
listItem.innerHTML = `
<span>
<i class="${item.icon.class}"></i> ${item.name}
</span>
<button class="btn btn-outline-secondary btn-sm">Open</button>
`;
listItem.querySelector('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'); const copyButton = document.getElementById('dynamicCopyButton');
copyButton.onclick = () => { copyButton.onclick = () => {
modalContent.select(); modalContent.select();
@@ -94,20 +92,25 @@ function openDynamicPopup(subitem) {
}); });
}; };
// Modal anzeigen
const modal = new bootstrap.Modal(document.getElementById('dynamicModal')); const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
modal.show(); modal.show();
} }
function closeAllModals() { function closeAllModals() {
const modals = document.querySelectorAll('.modal.show'); const modals = document.querySelectorAll('.modal.show'); // Alle offenen Modals finden
modals.forEach(modal => { modals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal); const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) { if (modalInstance) {
modalInstance.hide(); modalInstance.hide(); // Modal ausblenden
} }
}); });
// Entferne die "modal-backdrop"-Elemente
const backdrops = document.querySelectorAll('.modal-backdrop'); const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => backdrop.remove()); backdrops.forEach(backdrop => backdrop.remove());
// Entferne die Klasse, die den Hintergrund ausgraut
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.style.paddingRight = ''; document.body.style.paddingRight = '';

View File

@@ -1,215 +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 rect = submenu.getBoundingClientRect();
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 () { document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) { tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl); new bootstrap.Tooltip(tooltipTriggerEl);
}); });
}); });

View File

@@ -1,58 +1,32 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{platform.titel}}</title> <title>{{company.titel}}</title>
<meta charset="utf-8" > <meta charset="utf-8" >
<link <link rel="icon" type="image/x-icon" href="{{company.favicon.cache}}">
rel="icon"
type="image/x-icon"
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
>
<!-- Bootstrap CSS only --> <!-- Bootstrap CSS only -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.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 --> <!-- 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 --> <!-- 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 --> <!-- 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 --> <!-- Markdown -->
<script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}"> <link rel="stylesheet" href="static/css/default.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
<!-- JQuery -->
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
</head> </head>
<body <body>
{% if apod_bg %}
style="
background-image: url('{{ apod_bg }}');
background-size: cover;
background-position: center;
background-attachment: fixed;
"
{% endif %}
>
<div class="container"> <div class="container">
<header class="header js-restore"> <header class="header">
<img <img src="{{company.logo.cache}}" alt="logo"/>
src="{{ url_for('static', filename=platform.logo.cache) }}" <h1>{{company.titel}}</h1>
alt="logo" <h2>{{company.subtitel}}</h2>
/> <br />
<h1>{{platform.titel}}</h1>
<h2>{{platform.subtitel}}</h2>
</header> </header>
{% set menu_type = "header" %} {% set menu_type = "header" %}
{% include "moduls/navigation.html.j2"%} {% include "moduls/navigation.html.j2"%}
<main id="main"> {% block content %}{% endblock %}
<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>
{% set menu_type = "footer" %} {% set menu_type = "footer" %}
{% include "moduls/navigation.html.j2" %} {% include "moduls/navigation.html.j2" %}
<footer class="footer"> <footer class="footer">
@@ -60,22 +34,14 @@
<p itemprop="name">{{ company.titel }} <br /> <p itemprop="name">{{ company.titel }} <br />
{{ company.subtitel }}</p> {{ company.subtitel }}</p>
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span> <span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p> <p><a href="{{company.imprint_url}}"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
</div> </div>
</footer> </footer>
</div> </div>
<!-- Include modal --> <!-- Include modal -->
{% include "moduls/modal.html.j2" %} {% include "moduls/modal.html.j2" %}
{% for name in [ <script src="{{ url_for('static', filename='js/modal.js') }}"></script>
'modal', <script src="{{ url_for('static', filename='js/submenus.js') }}"></script>
'navigation', <script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
'tooltip',
'container',
'fullwidth',
'fullscreen',
'iframe',
] %}
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
{% endfor %}
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +1,66 @@
import hashlib
import mimetypes
import os import os
import hashlib
import requests import requests
class CacheManager: class CacheManager:
"""
A class to manage caching of files, including creating temporary directories
and caching files locally with hashed filenames.
"""
def __init__(self, cache_dir="static/cache"): def __init__(self, cache_dir="static/cache"):
"""
Initialize the CacheManager with a cache directory.
:param cache_dir: The directory where cached files will be stored.
"""
self.cache_dir = cache_dir self.cache_dir = cache_dir
self._ensure_cache_dir_exists() self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self): def _ensure_cache_dir_exists(self):
os.makedirs(self.cache_dir, exist_ok=True) """
Ensure the cache directory exists. If it doesn't, create it.
"""
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
def clear_cache(self): def clear_cache(self):
"""
Clear all files in the cache directory.
"""
if os.path.exists(self.cache_dir): if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir): for filename in os.listdir(self.cache_dir):
path = os.path.join(self.cache_dir, filename) file_path = os.path.join(self.cache_dir, filename)
if os.path.isfile(path): if os.path.isfile(file_path):
os.remove(path) os.remove(file_path)
def cache_file(self, file_url): def cache_file(self, file_url):
hash_suffix = hashlib.blake2s( """
file_url.encode("utf-8"), Download a file and store it locally in the cache directory with a hashed filename.
digest_size=8,
).hexdigest()
parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1]
try: :param file_url: The URL of the file to cache.
resp = requests.get(file_url, stream=True, timeout=5) :return: The local path of the cached file.
resp.raise_for_status() """
except requests.RequestException: # Generate a hashed filename based on the URL
return None hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
hash_suffix = hash_object.hexdigest()
content_type = resp.headers.get("Content-Type", "") # Determine the base name for the file
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png" splitted_file_url = file_url.split("/")
filename = f"{base}_{hash_suffix}{ext}" 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) full_path = os.path.join(self.cache_dir, filename)
if not os.path.exists(full_path): # If the file already exists, return the cached path
with open(full_path, "wb") as f: if os.path.exists(full_path):
for chunk in resp.iter_content(1024): return full_path
f.write(chunk)
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: class ConfigurationResolver:
"""
A class to resolve `link` entries in a nested configuration structure.
Supports navigation through dictionaries, lists, and `children`.
"""
def __init__(self, config): def __init__(self, config):
"""
Initializes the ConfigurationResolver with a configuration dictionary.
Args:
config (dict): The configuration to resolve links in.
"""
self.config = config self.config = config
def resolve_links(self): 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) self._recursive_resolve(self.config, self.config)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements): def _recursive_resolve(self, current_config, root_config, path=""):
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):
""" """
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): 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()): for key, value in list(current_config.items()):
if key == "children": if key == "subitems" and isinstance(value, list):
if value is None or not isinstance(value, list): pass
raise ValueError( #self._debug(value,path,inspect.currentframe().f_lineno)
"Expected 'children' to be a list, but got " #for index, item in enumerate(current_config[key]):
f"{type(value).__name__} instead." # #self._debug(value,path,inspect.currentframe().f_lineno)
) # if "link" in item:
for item in value: # self._debug(value,path,inspect.currentframe().f_lineno)
if "link" in item: # self._recursive_resolve(item, root_config, path=f"{path}[{index}]")
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)
elif key == "link": elif key == "link":
# Found a `link` entry, attempt to resolve it
try: try:
loaded = self._find_entry( target = self._find_entry(root_config, value.lower().replace(" ", "_"))
root_config, self._mapped_key(value), False
) if isinstance(target, dict):
if isinstance(loaded, list) and len(loaded) > 2: self._debug(value,path,inspect.currentframe().f_lineno)
loaded = self._find_entry( # Replace the current dictionary with the resolved dictionary
root_config, self._mapped_key(value), False 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() except KeyError as e:
current_config.update(loaded) # Handle unresolved links
except Exception as e:
raise ValueError( raise ValueError(
f"Error resolving link '{value}': {str(e)}. " f"Key error while resolving link '{value}': {str(e)}. "
f"Current part: {key}, Current config: {current_config}" f"Current path: {path}, Current config: {current_config}"
+ (
f", Loaded: {loaded}"
if "loaded" in locals() or "loaded" in globals()
else ""
)
) )
else: else:
self._recursive_resolve(value, root_config) self._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): elif isinstance(current_config, list):
for item in current_config: # Traverse all items in the list
self._recursive_resolve(item, root_config) 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): def _find_entry(self, config, path):
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):
""" """
Finds an entry in the configuration by a dot-separated path. Finds an entry in the configuration by navigating a dot-separated path.
Supports both dictionaries and lists with `children` navigation.
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 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: for part in parts:
raise KeyError( part = part.replace(" ", "_") # Normalize part name
f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. " if isinstance(current, list):
f"Current dictionary: {current}" # Search for a matching entry in a list
) found = next(
else: (
current = current[key] 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: else:
# Invalid path segment
raise ValueError( raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. " 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 return current
def get_config(self): def get_config(self):
""" """
Returns the resolved configuration. Returns the fully resolved configuration.
Returns:
dict: The resolved configuration.
""" """
return self.config return self.config

View File

@@ -1,24 +0,0 @@
version: '3.8'
services:
portfolio:
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
ports:
- "${PORT:-5000}:${PORT:-5000}"
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,2 +0,0 @@
PORT=5001
FLASK_ENV=production

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

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,70 +0,0 @@
import unittest
from html.parser import HTMLParser
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
class AnchorCollector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
class TestNavigationTemplate(unittest.TestCase):
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
environment = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
)
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
)
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",
platform={
"titel": "Portfolio",
"logo": {"cache": "logo.png"},
},
navigation={
"header": {
"children": [
{
"name": "Apps",
"description": "Application menu",
"icon": {"class": "fa-solid fa-grid"},
"children": [
{
"name": "Example",
"description": "Example app",
"icon": {"class": "fa-solid fa-link"},
"url": "https://example.test",
}
],
}
]
}
},
)
parser = AnchorCollector()
parser.feed(rendered)
dropdown_toggles = [
anchor
for anchor in parser.anchors
if "nav-link" in anchor.get("class", "")
and "dropdown-toggle" in anchor.get("class", "")
]
self.assertEqual(len(dropdown_toggles), 1)
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
if __name__ == "__main__":
unittest.main()

View File

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

View File

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