Compare commits

..

166 Commits

Author SHA1 Message Date
f8c2b4236b Added d-flex to place logo next to brand 2025-07-21 14:24:28 +02:00
dc2626e020 Added test for log 2025-07-21 12:18:07 +02:00
46b0b744ca Added logo to navbar when in fullscreen 2025-07-21 11:39:59 +02:00
5f2e7ef696 Changed pkgmgr commands 2025-07-12 18:54:42 +02:00
152a85bfb8 Merge branch 'main' of github.com:kevinveenbirkenbach/portfolio 2025-07-12 18:53:03 +02:00
fdfe301868 Renamed PortWebUI to PortUI 2025-07-12 18:52:51 +02:00
cbfe1ed8ae Update README.md
Solved another layout bug
2025-07-12 18:27:47 +02:00
9470162236 Solved formatation bug 2025-07-12 18:26:51 +02:00
6a57fa1e00 Optimized README.md 2025-07-12 18:24:49 +02:00
ab67fc0b29 Renamed portfolio to PortWebUI 2025-07-12 18:22:19 +02:00
e18566d801 Solved some bugs 2025-07-09 22:20:58 +02:00
7bc0f32145 Added cypress tests 2025-07-08 17:16:57 +02:00
6ed3e60dd0 Solved 2tap fullscreen hight bug 2025-07-08 14:39:13 +02:00
ab8ea0dbd6 Added iframe observer 2025-07-07 23:40:35 +02:00
b0446dcd29 Added include svgs 2025-07-07 19:14:29 +02:00
55d309b2d7 Changed fade between html iframe animation 2025-07-07 15:37:24 +02:00
d99a8c8452 Added restore functionality to small logo 2025-07-07 15:06:36 +02:00
3f6a195ecb Optimizid hover 2025-07-07 13:37:02 +02:00
430ea4a120 Solved loading bug 2025-07-07 13:19:49 +02:00
cc0fc9b77f Replaced object by svg 2025-07-07 12:46:56 +02:00
9ada9acb3a Implemented SVG handling 2025-07-07 12:40:25 +02:00
246ef1b059 Added backup logik for missing images 2025-07-07 08:57:30 +02:00
6572a39d48 Added hover effects to cards 2025-07-06 22:25:22 +02:00
a80262c0d4 Solved container height bug 2025-07-06 22:18:28 +02:00
531c2295bd Added header/footer animation 2025-07-06 18:14:42 +02:00
0640ec6439 Added animation for fullscreen log 2025-07-06 17:46:51 +02:00
a7eb14046f Added fullwidth animation 2025-07-06 17:35:40 +02:00
539580ad09 Added missing enterfullscreen function 2025-07-06 17:13:15 +02:00
faf5bd1e8c Solved iframe margin bug 2025-07-06 10:55:12 +02:00
97378422bd Solved more CSS bugs 2025-07-05 21:07:31 +02:00
2632c21de3 Removed unnecessary log messages 2025-07-05 20:33:08 +02:00
64db9a4e6a Implemented Nasa Picture of the day 2025-07-05 20:08:00 +02:00
d0f8d7d172 Added logo for small screen 2025-07-05 18:54:18 +02:00
20b6c731b8 Added onclick functionality for menu items 2025-07-05 18:32:26 +02:00
2f63009c31 Implemented full width function 2025-07-05 18:00:23 +02:00
f0d4206731 Finished resize implementation for iframe 2025-07-05 16:53:25 +02:00
b8aad8b695 Removed non functional resize code 2025-07-05 14:39:18 +02:00
697696347f finished fullscreen base implementation 2025-07-05 14:20:52 +02:00
d6389157ec Added fullscreen mode 2025-07-05 13:30:25 +02:00
25dbc3f331 Added correct iframe size loading 2025-07-05 13:17:38 +02:00
bb8799eb8a Added functionality for iframe url 2025-07-05 11:54:20 +02:00
86fd72b623 Solved wrong environment bug 2025-07-05 11:41:18 +02:00
9c24a8658f Solved other port mapping issues 2025-07-05 11:06:42 +02:00
5fc19f6ccb Solved other port bugs 2025-07-05 10:55:32 +02:00
35bfeeb51e Added correct env path import 2025-07-05 10:12:49 +02:00
dfbc840c69 Optimized default port 2025-07-05 09:40:11 +02:00
1bea9703ea Added port via env 2025-07-05 09:32:07 +02:00
4d68ed2a24 Solved caching bug 2025-07-01 23:28:25 +02:00
a0c7a7e8ca Added exception for debugging 2025-04-10 14:01:53 +02:00
3ec92ff853 Update README.md 2025-04-08 18:12:08 +02:00
8cb2f578df Added --delete and browse 2025-03-21 18:36:00 +01:00
412a7bae16 Added helper functions for portfolio 2025-03-20 00:23:35 +01:00
8e280de139 Added header h1 pointer 2025-03-19 17:20:08 +01:00
19f47a82fa Implemented iframe logic for modals 2025-03-19 17:16:44 +01:00
3b4dc298f8 Rafactored iframe.js 2025-03-19 16:53:49 +01:00
79e10e97b7 Marked header h1 as clickable 2025-03-19 16:16:27 +01:00
f5a9838474 Added logic for reload via header 2025-03-19 16:14:06 +01:00
242d1b9948 Implemented iframes for menu items and imprint 2025-03-19 15:54:19 +01:00
3db9872791 deleted test file 2025-03-19 15:48:34 +01:00
6a0db00f24 Modified scrollbars for iframes 2025-03-18 15:03:03 +01:00
3529749df5 Added iframe draft 2025-03-18 14:59:54 +01:00
ae775916b0 Replaced German by English comments 2025-03-18 14:27:07 +01:00
45969feaed Refactored card logic 2025-03-18 14:10:30 +01:00
464d307ee8 Optimized nav corners 2025-03-18 14:03:19 +01:00
4aceb2ed62 Solved main shadow bug 2025-03-18 13:56:42 +01:00
a8a2efd091 Solved scrollbar issues 2025-03-18 13:49:35 +01:00
3284684282 Solved main size bug 2025-03-18 13:30:38 +01:00
20c4a4809b Refactored css 2025-03-18 13:10:35 +01:00
898f7479c9 Added scrollbar draft 2025-03-18 12:50:14 +01:00
56513230e4 Implemented flexible card box sizes depending on card box amount 2025-03-18 03:56:37 +01:00
c35f44baef Added Funding 2025-03-12 20:52:48 +01:00
ef7059e748 Solved title bug 2025-03-12 11:14:40 +01:00
6597fb2862 Implemented better differenciation between platform and company 2025-02-19 23:47:34 +01:00
6ba6b2ea99 Implemented to set just class for cards 2025-02-19 22:41:41 +01:00
94b4e1f883 Added support for css logos 2025-02-19 20:53:05 +01:00
e03e740149 Solved bug 2025-01-17 10:56:45 +01:00
c96702035f Updated README.md 2025-01-17 10:34:21 +01:00
dc11dc799b Optimized path mapping 2025-01-17 02:14:48 +01:00
8c7dc02bd5 Updated README.md 2025-01-17 02:12:09 +01:00
9741da0495 Refactored modal.html.j2 2025-01-17 01:13:39 +01:00
0f8113974f Refactored warning and info js 2025-01-17 01:07:48 +01:00
a0664691e6 Refactored alternatives and options js 2025-01-17 01:04:27 +01:00
0360c443b7 Solved childrens selector bug 2025-01-17 00:59:26 +01:00
954cff051a Added children 2025-01-17 00:44:28 +01:00
7f78e77a10 Updated README.md 2025-01-16 23:12:49 +01:00
1c6b70d640 Updated README.md 2025-01-16 22:56:02 +01:00
f664270b5d renamed to config.sample.yaml 2025-01-16 22:52:58 +01:00
11eccf2eca Integrated buttons für nav 2025-01-15 13:54:29 +01:00
120465b46a Optimized for mobile 2025-01-15 13:47:51 +01:00
d1bbecd71b Reactivated navbar toggle 2025-01-15 13:21:42 +01:00
69fabafd9a Refactored code and implemented that menüs open to the top 2025-01-15 13:07:02 +01:00
82c111973d Optimized little thigns 2025-01-15 03:08:45 +01:00
3acf7d36a4 Added skill matrix 2025-01-15 03:03:42 +01:00
2e89e8c31e Optimized text 2025-01-15 02:54:57 +01:00
ef0d98cdd1 Optimized video channels 2025-01-15 02:49:10 +01:00
9f143e39b4 Removed link opening bug 2025-01-15 02:19:33 +01:00
8ad3ca54cc Optimized language credentials 2025-01-15 02:08:49 +01:00
9ff356ba70 Refactored code and implemented new childrens loading 2025-01-15 01:35:50 +01:00
c01e9125aa final solving all of menu bugs 2025-01-14 21:31:02 +01:00
c24e35c4e8 final solving of menu hover bug 2025-01-14 21:05:26 +01:00
b74ff2da78 Implemented reinitialisation of event listeners 2025-01-14 20:01:48 +01:00
066f10edfc Solved menu open too top bug 2025-01-14 19:33:13 +01:00
abdaf54147 refaktored 2025-01-14 19:25:07 +01:00
ac0b1e9a14 Refactored code 2025-01-14 19:12:43 +01:00
f9d5a90f94 Optimized menu bug. now distance from main menu to submenu of 1 item exist 2025-01-14 18:47:03 +01:00
00e0096f8a Optimized menu bug 2025-01-14 17:52:31 +01:00
f017cacebe Optimized logic in which direction menus open 2025-01-14 17:34:30 +01:00
7c51ac6bbc Refactored navigation code 2025-01-14 17:19:09 +01:00
573a3be360 Optimized menus for smartphone 2025-01-14 17:08:59 +01:00
1eb673454c Updated README.md 2025-01-14 16:57:13 +01:00
0809272458 added credentials 2025-01-11 17:25:06 +01:00
27445621c9 Merge branch 'main' of github.com:kevinveenbirkenbach/homepage.veen.world 2025-01-11 15:37:41 +01:00
319cb7ff72 Optimized menus 2025-01-10 19:17:24 +01:00
1d6f8eb0a5 Update docker-compose.yml 2025-01-10 18:32:00 +01:00
1395304a35 Create docker-compose.yml 2025-01-10 18:30:59 +01:00
ae6eb6d802 added correct css for navbar 2025-01-10 15:24:51 +01:00
c9952038d4 Updated configuration 2025-01-10 15:21:23 +01:00
e08c835598 Optimized configuration 2025-01-10 15:12:05 +01:00
ced25bdf3b Refactored to accounts 2025-01-10 14:17:26 +01:00
a60b3893aa Solved url bug 2025-01-10 14:09:28 +01:00
71209df82e Refactored code 2025-01-10 14:08:21 +01:00
9b8e9a0f1c Solved submenu bug 2025-01-10 14:05:53 +01:00
28cd3e1f2f Solved link bug with subitems 2025-01-10 13:56:37 +01:00
dc058d16df Optimized whatsapp alternatives (n) 2025-01-10 11:53:49 +01:00
bbc9abc7b9 Pulled app/utils/cache_manager.py 2025-01-10 11:48:09 +01:00
9aaf86a33c Pulled app.py 2025-01-10 11:46:51 +01:00
9d510ec8fb Pulled navigation.html.j2 2025-01-10 11:45:22 +01:00
8b958c8947 Updated README.md 2025-01-10 11:41:32 +01:00
3c240fc16b Solved bug 2025-01-09 16:34:35 +01:00
2a3491b98b Added warnings 2025-01-09 16:30:38 +01:00
378ee4632f Finish modals 2025-01-09 15:57:39 +01:00
19f99ff9d3 Optimized description 2025-01-09 15:46:45 +01:00
a9fcd4b6de Solved close modals bug 2025-01-09 15:38:48 +01:00
e303968ca5 Solved bugs 2025-01-09 15:34:32 +01:00
f85dc5bb18 Removed allerts 2025-01-09 15:19:37 +01:00
562f5989e1 Solved identifier bug 2025-01-09 15:17:34 +01:00
9455f40079 Optimized modals 2025-01-09 14:59:30 +01:00
d59cc73470 Implemented hover submenüs 2025-01-09 14:42:38 +01:00
7a66184a46 Added dynamic submenus 2025-01-09 14:36:44 +01:00
d8ec067675 Refactored App.py 2025-01-09 14:27:07 +01:00
c87c1df10a Finished implementation of configuration resolver 2025-01-09 14:20:59 +01:00
8fb0cecfbe Added detailled debug infos 2025-01-09 13:56:29 +01:00
61af45e837 Added conf resv base 2025-01-09 13:53:56 +01:00
4ee5340dd3 Optimized config and .gitignore 2025-01-09 13:19:54 +01:00
14ccedf1c1 modified icon function 2025-01-09 12:20:57 +01:00
8959f4405b Removed pictures 2025-01-09 12:03:34 +01:00
e45bd16631 Optimized caching and changed from json to yaml 2025-01-09 11:59:23 +01:00
9b763cd34b Optimized navigation 2025-01-09 09:48:56 +01:00
00e8047fb7 Added eversport activities 2025-01-08 22:15:36 +01:00
4ca34b55de Optimized menu 2025-01-08 22:02:15 +01:00
861fd29d45 Added tooltip js 2025-01-08 20:32:00 +01:00
f9af3e33b8 Added tooltipps 2025-01-08 20:17:32 +01:00
4b2c251e79 Optimized menus 2025-01-08 19:37:24 +01:00
cc04bbf0f5 Implemented header and footer menu 2025-01-08 18:06:08 +01:00
59eebbeb92 Implemented draft for contacts 2025-01-08 17:09:45 +01:00
8f96346a6b Implemented the python draft 2025-01-08 14:59:36 +01:00
f962dbb31c Changed logos 2024-12-28 18:49:39 +01:00
084d0f7c84 Added skydiving icon 2024-12-05 10:33:41 +01:00
a2f1668490 Added more links 2024-12-04 21:47:11 +01:00
68287b9bca Implemented logbooks 2024-04-08 15:12:03 +02:00
8b96857d23 Changed title to Master Diver 2024-03-27 17:38:47 +01:00
559be3ba6d Changed hypno domain 2024-02-07 22:09:32 +01:00
1e147f21ed Implemented diver, hunter, massage therapist and skydiver 2024-02-07 21:30:54 +01:00
4d9b69469a Optimized Homepage 2024-02-04 22:35:55 +01:00
f4932c3166 shortend agbs 2023-12-03 21:19:54 +01:00
eed76d71dd added agbs 2023-12-03 21:12:45 +01:00
52 changed files with 3369 additions and 302 deletions

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<comment version="3.0">
<caption/>
<note>Kevin Veen-Birkenbach Logo</note>
<place/>
<categories/>
</comment>

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

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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
app/config.yaml
*__pycache__*
app/static/cache/*
.env
app/cypress/screenshots/*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Base image for Python
FROM python:slim
# Set the working directory
WORKDIR /app
# Copy and install dependencies
COPY app/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ .
CMD ["python", "app.py"]

82
Makefile Normal file
View File

@@ -0,0 +1,82 @@
# 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
# Default port (can be overridden with PORT env var)
.PHONY: build
build:
# Build the Docker image.
docker build -t application-portfolio .
.PHONY: up
up:
# Start the application using docker-compose with build.
docker-compose up -d --build
.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)
npm-install:
cd app && npm install
test: npm-install
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"

162
README.md
View File

@@ -1 +1,161 @@
# homepage.veen.world
# PortUI 🖥️✨
[![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)
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)
---
## ✨ Key Features
- **Dynamic Navigation**
Create dropdowns & nested menus with ease.
- **Customizable Cards**
Highlight skills, projects, or services—with icons, titles, and links.
- **Smart Cache Management**
Auto-cache assets for lightning-fast loading.
- **Responsive Design**
Built on Bootstrap; looks great on desktop, tablet & mobile.
- **YAML-Driven**
All content & structure defined in a simple `config.yaml`.
- **CLI Control**
Manage Docker containers via the `portfolio` command.
---
## 🌐 Quick Access
- **Local Preview:**
[http://127.0.0.1:5000](http://127.0.0.1:5000)
---
## 🏁 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
```bash
pkgmgr install portui
```
Once installed, the `portui` CLI is available system-wide.
---
## 🖥️ CLI Commands
```bash
portui --help
```
* `build`Build the Docker image
* `up`Start containers (with build)
* `down`Stop & remove containers
* `run-dev`Dev mode (hot-reload)
* `run-prod`Production mode
* `logs`View container logs
* `dev`Docker-Compose dev environment
* `prod`Docker-Compose prod environment
* `cleanup`Prune stopped containers
---
## 🔧 YAML Configuration Guide
Define your sites structure in `config.yaml`:
```yaml
accounts:
name: Online Accounts
description: Discover my online presence.
icon:
class: fa-solid fa-users
children:
- name: Channels
description: Platforms where I share content.
icon:
class: fas fa-newspaper
children:
- name: Mastodon
description: Follow me on Mastodon.
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
identifier: "@kevinveenbirkenbach@microblog.veen.world"
cards:
- icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
url: https://www.agile-coach.world
link_text: www.agile-coach.world
company:
title: Kevin Veen-Birkenbach
subtitle: Consulting & Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_face_512x512/download
favicon:
source: https://cloud.veen.world/s/veen_world_favicon/download
address:
street: Afrikanische Straße 43
postal_code: DE-13351
city: Berlin
country: Germany
imprint_url: https://s.veen.world/imprint
```
* **`children`** enables multi-level menus.
* **`link`** references other YAML paths to avoid duplication.
---
## 🚢 Production Deployment
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
---
## 📜 License
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
---
## ✍️ Author
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
Enjoy building your portfolio! 🌟

2
app/.gitignore vendored Normal file
View File

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

117
app/app.py Normal file
View File

@@ -0,0 +1,117 @@
import os
from flask import Flask, render_template
import yaml
import requests
from utils.configuration_resolver import ConfigurationResolver
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
import logging
logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_PORT = int(os.getenv("PORT", 5000))
print(f"🔧 Starting app on port {FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
from flask import Flask, render_template, current_app
from markupsafe import Markup
# Initialize the CacheManager
cache_manager = CacheManager()
# Clear cache on startup
cache_manager.clear_cache()
def load_config(app):
"""Load and resolve the configuration from config.yaml."""
with open("config.yaml", "r") as f:
config = yaml.safe_load(f)
if config.get("nasa_api_key"):
app.config["NASA_API_KEY"] = config["nasa_api_key"]
resolver = ConfigurationResolver(config)
resolver.resolve_links()
app.config.update(resolver.get_config())
def cache_icons_and_logos(app):
"""Cache all icons and logos to local files, mit Fallback auf source."""
for card in app.config["cards"]:
icon = card.get("icon", {})
if icon.get("source"):
cached = cache_manager.cache_file(icon["source"])
# Fallback: wenn cache_file None liefert, nutze weiterhin source
icon["cache"] = cached or icon["source"]
# Company-Logo
company_logo = app.config["company"]["logo"]
cached = cache_manager.cache_file(company_logo["source"])
company_logo["cache"] = cached or company_logo["source"]
# Platform Favicon
favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"]
# Platform Logo
platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"]
# Initialize Flask app
app = Flask(__name__)
# Load configuration and cache assets on startup
load_config(app)
cache_icons_and_logos(app)
@app.context_processor
def utility_processor():
def include_svg(path):
full_path = os.path.join(current_app.root_path, 'static', path)
try:
with open(full_path, 'r', encoding='utf-8') as f:
svg = f.read()
return Markup(svg)
except IOError:
return Markup(f'<!-- SVG not found: {path} -->')
return dict(include_svg=include_svg)
@app.before_request
def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode."""
if FLASK_ENV == "development":
load_config(app)
cache_icons_and_logos(app)
@app.route('/')
def index():
"""Render the main index page."""
cards = app.config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
# fetch NASA APOD URL only if key present
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}
)
if resp.ok:
data = resp.json()
# only use if it's an image
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__":
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=FLASK_PORT)

683
app/config.sample.yaml Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

5
app/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"cypress": "^14.5.1"
}
}

3
app/requirements.txt Normal file
View File

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

View File

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

199
app/static/css/default.css Normal file
View File

@@ -0,0 +1,199 @@
@import url("navigation.css");
/* General link styles */
a {
text-decoration: none;
color: #000000;
}
/* Header styles */
.header img {
float: right;
width: 100px;
height: 100px;
}
.header h1 {
position: relative;
}
/* Equal-height container using flexbox */
.equal-height {
display: flex;
flex: 1;
}
/* Subtle shadow effect */
.navbar, .card, .dropdown-menu {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* Card styles */
.navbar, .card {
flex: 1; /* Ensures cards fill the height of their container */
border-radius: 5px; /* Rounded corners */
border: 1px solid #ccc; /* Optional border color */
padding: 10px; /* Inner spacing */
color: #000000 !important;
background-color: #f9f9f9;
}
.card {
transition: background-color 1s ease, transform 1s ease;
transition: color 1s ease, transform 1s ease;
}
.card:hover {
/* invert everything inside the card */
filter: invert(0.8) hue-rotate(144deg);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.card-body {
display: flex;
flex-direction: column;
align-items: center; /* Center content horizontally */
text-align: center; /* Center text alignment */
}
.card-icon {
display: flex;
justify-content: center; /* Center the icon horizontally */
}
.card-text,
.card ul {
text-align: left; /* Align text to the left */
}
.card-column {
padding-top: 12px;
padding-bottom: 12px;
}
.card .stretched-link {
font-size: 0.7em;
}
h3.card-title {
font-size: 1.3em;
}
/* Footer styles */
.footer {
text-align: center;
font-size: 0.7em;
}
.footer p,
.footer h3 {
margin: 0;
padding: 0;
}
h3.footer-title {
font-size: 1.3em;
}
.card-img-top i, .card-img-top svg{
font-size: 100px;
fill: currentColor;
width: 100px;
height: auto;
}
div#navbarNavheader li.nav-item {
margin-left: 6px;
}
div#navbarNavfooter li.nav-item {
margin-right: 6px;
}
main, footer, header, nav {
position: relative;
box-shadow:
/* Inner shadow */
inset 10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Left inner shadow */
inset -10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right inner shadow */
/* Outer shadow */
10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right outer shadow */
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
overflow: visible;
}
header{
padding: 12px;
}
header,
footer {
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: 0;
z-index: 1030;
background-color: var(--bs-light);
}
/* at the end of default.css */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
iframe{
margin-bottom: -10px;
}
.container-fluid {
max-width: 100% !important;
}
:root {
--anim-duration: 3s; /* Basis-Dauer */
}
.container,
.container-fluid {
transition:
max-width var(--anim-duration) ease-in-out,
padding-left var(--anim-duration) ease-in-out,
padding-right var(--anim-duration) ease-in-out;
}
#navbar_logo {
/* start invisible but in the layout (d-none will actually hide it) */
opacity: 0;
transition: opacity var(--anim-duration) ease-in-out;
}
#navbar_logo.visible {
opacity: 1 !important;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */
max-height: 200px;
padding: 1rem;
transition:
max-height var(--anim-duration) ease-in-out,
padding var(--anim-duration) ease-in-out;
}
/* 2. In fullscreen mode, collapse them */
body.fullscreen header,
body.fullscreen footer {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}

View File

@@ -0,0 +1,24 @@
/* Top-level dropdown menu */
.nav-item .dropdown-menu {
position: absolute; /* Important for positioning */
top: 100%; /* Default opening direction: downwards */
left: 0;
z-index: 1050; /* Ensures the menu appears above other elements */
}
/* 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;
}

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

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

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

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

View File

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

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

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

114
app/static/js/modal.js Normal file
View File

@@ -0,0 +1,114 @@
function openDynamicPopup(subitem) {
closeAllModals();
const modalTitle = document.getElementById('dynamicModalLabel');
if (subitem.icon && subitem.icon.class) {
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
} else {
modalTitle.innerText = subitem.name;
}
const identifierBox = document.getElementById('dynamicIdentifierBox');
const modalContent = document.getElementById('dynamicModalContent');
if (subitem.identifier) {
identifierBox.classList.remove('d-none');
modalContent.value = subitem.identifier;
} else {
identifierBox.classList.add('d-none');
modalContent.value = '';
}
function toggleBox(boxId, textId, content) {
const box = document.getElementById(boxId);
if (content) {
box.classList.remove('d-none');
document.getElementById(textId).innerHTML = marked.parse(content);
} else {
box.classList.add('d-none');
}
}
toggleBox('dynamicModalWarning', 'dynamicModalWarningText', subitem.warning);
toggleBox('dynamicModalInfo', 'dynamicModalInfoText', subitem.info);
const descriptionText = document.getElementById('dynamicDescriptionText');
if (!subitem.url && subitem.description) {
descriptionText.classList.remove('d-none');
descriptionText.innerText = subitem.description;
} else {
descriptionText.classList.add('d-none');
descriptionText.innerText = '';
}
const linkBox = document.getElementById('dynamicModalLink');
const linkHref = document.getElementById('dynamicModalLinkHref');
if (subitem.url) {
linkBox.classList.remove('d-none');
linkHref.href = subitem.url;
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 {
linkBox.classList.add('d-none');
linkHref.href = '#';
}
function populateSection(sectionId, listId, items, onClickHandler) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
list.innerHTML = '';
if (items && items.length > 0) {
section.classList.remove('d-none');
items.forEach(item => {
const listItem = document.createElement('li');
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
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);
const copyButton = document.getElementById('dynamicCopyButton');
copyButton.onclick = () => {
modalContent.select();
navigator.clipboard.writeText(modalContent.value).then(() => {
alert('Identifier copied to clipboard!');
});
};
const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
modal.show();
}
function closeAllModals() {
const modals = document.querySelectorAll('.modal.show');
modals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
}
});
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => backdrop.remove());
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}

121
app/static/js/navigation.js Normal file
View File

@@ -0,0 +1,121 @@
document.addEventListener('DOMContentLoaded', () => {
const menuItems = document.querySelectorAll('.nav-item.dropdown');
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
function addMenuEventListeners(items, isTopLevel) {
items.forEach(item => {
let timeout;
function onMouseEnter() {
clearTimeout(timeout);
openMenu(item, isTopLevel);
}
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) => {
e.stopPropagation(); // Prevents menus from closing when clicking inside
if (item.classList.contains('open')) {
closeMenu(item);
} else {
openMenu(item, isTopLevel);
}
});
});
}
function addAllMenuEventListeners() {
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
addMenuEventListeners(updatedMenuItems, true);
addMenuEventListeners(updatedSubMenuItems, false);
}
addAllMenuEventListeners();
// Global click listener to close menus when clicking outside
document.addEventListener('click', () => {
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
});
function openMenu(item, isTopLevel) {
item.classList.add('open');
const submenu = item.querySelector('.dropdown-menu');
if (submenu) {
submenu.style.display = 'block';
submenu.style.opacity = '1';
submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel);
}
}
function closeMenu(item) {
item.classList.remove('open');
const submenu = item.querySelector('.dropdown-menu');
if (submenu) {
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`;
}
}
}
});

7
app/static/js/tooltip.js Normal file
View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }}
{% elif card.icon.cache %}
<img
src="{{ url_for('static', filename=card.icon.cache) }}"
alt="{{ card.title }}"
style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
{% if card.icon.class %}
<i class="{{ card.icon.class }}" style="display:none;"></i>
{% endif %}
{% elif card.icon.class %}
<i class="{{ card.icon.class }}"></i>
{% endif %}
</div>
<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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
import os
import hashlib
import requests
import mimetypes
class CacheManager:
def __init__(self, cache_dir="static/cache"):
self.cache_dir = cache_dir
self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self):
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
def clear_cache(self):
if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir):
path = os.path.join(self.cache_dir, filename)
if os.path.isfile(path):
os.remove(path)
def cache_file(self, file_url):
# generate a short hash for filename
hash_suffix = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8).hexdigest()
parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1]
try:
resp = requests.get(file_url, stream=True, timeout=5)
resp.raise_for_status()
except requests.RequestException:
return None
content_type = resp.headers.get('Content-Type', '')
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
filename = f"{base}_{hash_suffix}{ext}"
full_path = os.path.join(self.cache_dir, filename)
if not os.path.exists(full_path):
with open(full_path, "wb") as f:
for chunk in resp.iter_content(1024):
f.write(chunk)
# return path relative to /static/
return f"cache/{filename}"

View File

@@ -0,0 +1,42 @@
def compute_card_classes(cards):
num_cards = len(cards)
lg_classes = []
if num_cards < 3:
if num_cards == 2:
lg_classes = ["col-lg-6", "col-lg-6"]
else:
lg_classes = ["col-lg-12"]
elif num_cards % 4 == 0:
lg_classes = ["col-lg-3"] * num_cards
elif num_cards % 3 == 0:
lg_classes = ["col-lg-4"] * num_cards
elif num_cards % 2 == 0:
lg_classes = ["col-lg-6"] * num_cards
else:
# For complex cases (e.g., 5, 7, 11) Ensure at least 3 per row
for i in range(num_cards):
if num_cards % 4 == 3:
if i < 3:
lg_classes.append("col-lg-4")
else:
lg_classes.append("col-lg-3")
elif num_cards % 4 == 1:
if i < 2:
lg_classes.append("col-lg-6")
elif i < 5:
lg_classes.append("col-lg-4")
else:
lg_classes.append("col-lg-3")
elif num_cards % 3 == 2:
if i < 2:
lg_classes.append("col-lg-6")
else:
lg_classes.append("col-lg-4")
# md classes: If the number of cards is even or if not the last card, otherwise "col-md-12"
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

@@ -0,0 +1,147 @@
from pprint import pprint
class ConfigurationResolver:
"""
A class to resolve `link` entries in a nested configuration structure.
Supports navigation through dictionaries, lists, and `children`.
"""
def __init__(self, config):
self.config = config
def resolve_links(self):
"""
Resolves all `link` entries in the configuration.
"""
self._recursive_resolve(self.config, self.config)
def __load_children(self,path):
"""
Check if explicitly children should be loaded and not parent
"""
return path.split('.').pop() == "children"
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
if old_key in dict_origine:
# Entferne den alten Key
old_value = dict_origine.pop(old_key)
# Füge die neuen Key-Value-Paare hinzu
dict_origine.update(new_dict)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
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.
"""
if isinstance(current_config, dict):
for key, value in list(current_config.items()):
if key == "children":
if value is None or not isinstance(value, list):
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
for item in value:
if "link" in item:
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
if isinstance(loaded_link, list):
self._replace_in_list_by_list(value,item,loaded_link)
else:
self._replace_element_in_list(value,item,loaded_link)
else:
self._recursive_resolve(value, root_config)
elif key == "link":
try:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
if isinstance(loaded, list) and len(loaded) > 2:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
current_config.clear()
current_config.update(loaded)
except Exception as e:
raise ValueError(
f"Error resolving link '{value}': {str(e)}. "
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
)
else:
self._recursive_resolve(value, root_config)
elif isinstance(current_config, list):
for item in current_config:
self._recursive_resolve(item, root_config)
def _get_children(self,current):
if isinstance(current, dict) and ("children" in current and current["children"]):
current = current["children"]
return current
def _mapped_key(self,name):
return name.replace(" ", "").lower()
def _find_by_name(self,current, part):
return next(
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
None
)
def _find_entry(self, config, path, children):
"""
Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation.
"""
parts = path.split('.')
current = config
for part in parts:
if isinstance(current, list):
# If children explicit declared just load children
if part != "children":
# Look for a matching name in the list
found = self._find_by_name(current,part)
if found:
current = found
print(
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current list: {current}"
)
else:
raise ValueError(
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current list: {current}"
)
elif isinstance(current, dict):
# Case-insensitive dictionary lookup
key = next((k for k in current if self._mapped_key(k) == part), None)
# If no fitting key was found search in the children
if key is None:
if "children" not in current:
raise KeyError(
f"No 'children' found in current dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current dictionary: {current}"
)
# The following line seems buggy; Why is children loaded allways and not just when children is set?
current = self._find_by_name(current["children"],part)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current dictionary: {current}"
)
else:
current = current[key]
else:
raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. "
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
)
if children:
current = self._get_children(current)
return current
def get_config(self):
"""
Returns the resolved configuration.
"""
return self.config

15
docker-compose.yml Normal file
View File

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

2
env.example Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,40 +0,0 @@
<html>
<header>
<title>Impressum</title>
<meta charset="utf-8" >
</header>
<body>
<h1>Impressum</h1>
<h2>Angaben gem&auml;&szlig; &sect; 5 TMG</h2>
<p>Kevin Veen-Birkenbach<br />
Beratungs- und Coachingdienstleistungen<br />
Afrikanische Stra&szlig;e 43<br />
13351 Berlin</p>
<h2>Kontakt</h2>
<p>Telefon: +491781798023<br />
E-Mail: kevin@veen.world</p>
<h2>Umsatzsteuer-ID</h2>
<p>Umsatzsteuer-Identifikationsnummer gem&auml;&szlig; &sect; 27 a Umsatzsteuergesetz:<br />
23/569/00564</p>
<h2>Angaben zur Berufs&shy;haftpflicht&shy;versicherung</h2>
<p><strong>Name und Sitz des Versicherers:</strong><br />
Markel Insurance SE<br />
Sophienstr. 26<br />
80333 M&uuml;nchen<br />
Registergericht: Amtsgericht M&uuml;nchen<br />
Handelsregisternummer: HRB 233618</p>
<p><strong>Geltungsraum der Versicherung:</strong><br />Weltweit</p>
<h2>Redaktionell verantwortlich</h2>
<p>Kevin Veen-Birkenbach</p>
<h2>Verbraucher&shy;streit&shy;beilegung/Universal&shy;schlichtungs&shy;stelle</h2>
<p>Universalschlichtungsstelle des Bundes<br>
Zentrums für Schlichtung e.V.<br>
Straßburger Straße 8<br>
77694 Kehl am Rhein</p>
</body>

View File

@@ -1,254 +0,0 @@
<html>
<head>
<title>Kevin Veen-Birkenbach - Consulting and Coaching Services</title>
<meta charset="utf-8" >
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Bootstrap CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<!-- Bootstrap JavaScript Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
<!-- Fontawesome -->
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script>
<style>
a {
text-decoration: none;
color: #000000;
}
.header img {
float: right;
width: 100px;
height: 100px;
}
.header h1 {
position: relative;
}
.equal-height {
display: flex;
flex: 1;
}
.card-body {
display: flex;
flex-direction: column;
align-items: center; /* Zentriert die Inhalte horizontal */
text-align: center; /* Zentriert den Text */
}
.card-icon {
display: flex;
justify-content: center; /* Zentriert das Icon horizontal */
}
.card-text,
.card ul {
text-align: left; /* Stellt sicher, dass der Text linksbündig ist */
}
.card{
flex: 1; /* Stellt sicher, dass die Karten die ganze Höhe ihrer Container ausfüllen */
}
h3.card-title{
font-size: 1.3em;
}
.card .stretched-link{
font-size: 0.7em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="512x512/logo.png" alt="logo"/>
<h1>Kevin Veen-Birkenbach</h1>
<h2>Consulting and Coaching Services</h2>
<p></p>
</div>
<div class="row">
<div class="col-md-6 col-lg-1 equal-height mt-2 mt-lg-0">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="512x512/logo_agile-coach.png" alt="Agile Coach" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">Agile Coach</h3>
<p class="card-text">I facilitate agile transformations, DevOps integration and improving team dynamics through Scrum Master, Release Train Engineer, Business Mediator and Agile Coach support.</p>
<a href="https://www.agile-coach.world" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.agile-coach.world</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-1 equal-height mt-2 mt-lg-0">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="512x512/logo_personal-coach.png" alt="Personal Coach" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">Personal Coach</h3>
<p class="card-text">I offer personal growth and professional development through customized coaching strategies, hypnotherapy, mediation, tantric and holistic improvement techniques.</p>
<a href="https://www.personalcoach.berlin" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.personalcoach.berlin</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-1 equal-height mt-2 mt-lg-0">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="512x512/logo_yachtmaster.png" alt="Yachtmaster" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">Yachtmaster</h3>
<p class="card-text">I teach and coach sailing, deliver yachts, help in planning voyages and offer services as skipper, ship chief cook, rescue swimmer and diver on yachts.</p>
<a href="https://www.yachtmaster.world" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.yachtmaster.world</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-1 equal-height mt-2 mt-lg-0">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="512x512/logo_cross-domain-consultant.png" alt="Cross Domain Consultant" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">Polymath</h3>
<p class="card-text">I help people and insititutions to appraise, plan, monitor, execute and evaluate complex cross-domain projects in the land, sea, sky, cyber, information and psychological domain.</p>
<a href="https://www.crossdomain.consulting/" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.crossdomain.consulting</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-1 equal-height mt-2 mt-lg-0">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
<img src="512x512/logo_cybermaster.png" alt="Cyber Master" style="width: 100px; height: auto;">
</div>
<hr />
<h3 class="card-title">Cyber Master</h3>
<p class="card-text">I develope robust open source IT infrastructure and software solutions tailored for German SMBs, with an emphasis on automation, security, reliability, and efficient operations.</p>
<a href="https://www.cybermaster.space" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.cybermaster.space</a>
</div>
</div>
</div>
</div>
<p></p>
<div class="row">
<div class="col-lg-3 col-md-6 col-sm-12 mt-4 sm-8">
<h3>Social</h3>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a rel="me" href="https://github.com/kevinveenbirkenbach">
<i class="bi bi-github"></i>
github.com
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://www.instagram.com/kevinveenbirkenbach/">
<i class="fa-brands fa-instagram"></i>
instagram.com
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://www.xing.com/profile/Kevin_VeenBirkenbach">
<i class="bi bi-building"></i>
xing.com
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://www.linkedin.com/in/kevinveenbirkenbach">
<i class="bi bi-linkedin"></i>
linkedin.com
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://www.facebook.com/kevinveenbirkenbach">
<i class="fa-brands fa-facebook"></i> facebook
</a>
</li>
<ul>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 mt-4 sm-8">
<h3>Fediverse</h3>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a rel="me" href="https://cloud.veen.world/u/kevinveenbirkenbach" class="link">
<i class="fa-solid fa-cloud"></i>
cloud.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://mastodon.veen.world/@kevinveenbirkenbach">
<i class="fa-brands fa-mastodon"></i>
mastodon.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://pixelfed.veen.world/kevinveenbirkenbach">
<i class="fa-solid fa-camera"></i>
pixelfed.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://peertube.veen.world/a/kevinveenbirkenbach">
<i class="fa-solid fa-video"></i>
peertube.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://blog.veen.world">
<i class="fa-solid fa-blog"></i>
blog.veen.world
</a>
</li>
<ul>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 mt-4 sm-8">
<h3>Other</h3>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a rel="me" href="https://matomo.veen.world/" class="link">
<i class="fa-solid fa-chart-simple"></i>
matomo.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://baserow.veen.world/" class="link">
<i class="fa-solid fa-table"></i>
baserow.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://git.veen.world/kevinveenbirkenbach" class="link">
<i class="fa-solid fa-code"></i>
git.veen.world
</a>
</li>
<li class="list-group-item">
<a rel="me" href="https://www.duolingo.com/profile/kevinbirkenbach">
<i class="fa-solid fa-language"></i> duolingo.com
</a>
</li>
<li class="list-group-item">
<a rel="me" href="imprint.html">
<i class="fa-solid fa-scale-balanced"></i> imprint
</a>
</li>
<ul>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 mt-4 sm-8 bg-light rounded">
<h3>Contact</h3>
<div itemscope itemtype="http://schema.org/LocalBusiness" class="small">
<p itemprop="name">Kevin Veen-Birkenbach <br/> Beratungs- und Coachingdienstleistungen</p>
<p itemprop="address">Afrikanische Straße 43<br>DE-13351 Berlin<br>Germany</p>
<p>Phone: <span itemprop="telephone"><a href="tel:+491781798023">+491781798023</a></span><br>
Email: <span itemprop="email"><a href="mailto:kevin@veen.world">kevin@veen.world</a></span><br>
PGP-Key: <a href="https://s.veen.world/pgp">s.veen.world/pgp</a></p>
</div>
</div>
</div>
</div>
</body>
</html>

84
main.py Executable file
View File

@@ -0,0 +1,84 @@
#!/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 subprocess
import sys
import os
import re
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') as f:
lines = f.readlines()
for idx, line in enumerate(lines):
m = pattern.match(line)
if m:
name = m.group(1)
help_text = ''
# look for next non-empty line
if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'):
help_text = lines[idx+1].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()
if not args.command:
parser.print_help()
sys.exit(1)
cmd = ["make", args.target]
run_command(cmd, dry_run=args.dry_run)
if __name__ == "__main__":
from pathlib import Path
main()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
python-dotenv