Compare commits
168 Commits
1424327bb2
...
main
Author | SHA1 | Date | |
---|---|---|---|
f8c2b4236b | |||
dc2626e020 | |||
46b0b744ca | |||
5f2e7ef696 | |||
152a85bfb8 | |||
fdfe301868 | |||
cbfe1ed8ae | |||
9470162236 | |||
6a57fa1e00 | |||
ab67fc0b29 | |||
e18566d801 | |||
7bc0f32145 | |||
6ed3e60dd0 | |||
ab8ea0dbd6 | |||
b0446dcd29 | |||
55d309b2d7 | |||
d99a8c8452 | |||
3f6a195ecb | |||
430ea4a120 | |||
cc0fc9b77f | |||
9ada9acb3a | |||
246ef1b059 | |||
6572a39d48 | |||
a80262c0d4 | |||
531c2295bd | |||
0640ec6439 | |||
a7eb14046f | |||
539580ad09 | |||
faf5bd1e8c | |||
97378422bd | |||
2632c21de3 | |||
64db9a4e6a | |||
d0f8d7d172 | |||
20b6c731b8 | |||
2f63009c31 | |||
f0d4206731 | |||
b8aad8b695 | |||
697696347f | |||
d6389157ec | |||
25dbc3f331 | |||
bb8799eb8a | |||
86fd72b623 | |||
9c24a8658f | |||
5fc19f6ccb | |||
35bfeeb51e | |||
dfbc840c69 | |||
1bea9703ea | |||
4d68ed2a24 | |||
a0c7a7e8ca | |||
3ec92ff853 | |||
8cb2f578df | |||
412a7bae16 | |||
8e280de139 | |||
19f47a82fa | |||
3b4dc298f8 | |||
79e10e97b7 | |||
f5a9838474 | |||
242d1b9948 | |||
3db9872791 | |||
6a0db00f24 | |||
3529749df5 | |||
ae775916b0 | |||
45969feaed | |||
464d307ee8 | |||
4aceb2ed62 | |||
a8a2efd091 | |||
3284684282 | |||
20c4a4809b | |||
898f7479c9 | |||
56513230e4 | |||
c35f44baef | |||
ef7059e748 | |||
6597fb2862 | |||
6ba6b2ea99 | |||
94b4e1f883 | |||
e03e740149 | |||
c96702035f | |||
dc11dc799b | |||
8c7dc02bd5 | |||
9741da0495 | |||
0f8113974f | |||
a0664691e6 | |||
0360c443b7 | |||
954cff051a | |||
7f78e77a10 | |||
1c6b70d640 | |||
f664270b5d | |||
11eccf2eca | |||
120465b46a | |||
d1bbecd71b | |||
69fabafd9a | |||
82c111973d | |||
3acf7d36a4 | |||
2e89e8c31e | |||
ef0d98cdd1 | |||
9f143e39b4 | |||
8ad3ca54cc | |||
9ff356ba70 | |||
c01e9125aa | |||
c24e35c4e8 | |||
b74ff2da78 | |||
066f10edfc | |||
abdaf54147 | |||
ac0b1e9a14 | |||
f9d5a90f94 | |||
00e0096f8a | |||
f017cacebe | |||
7c51ac6bbc | |||
573a3be360 | |||
1eb673454c | |||
0809272458 | |||
27445621c9 | |||
319cb7ff72 | |||
1d6f8eb0a5 | |||
1395304a35 | |||
ae6eb6d802 | |||
c9952038d4 | |||
e08c835598 | |||
ced25bdf3b | |||
a60b3893aa | |||
71209df82e | |||
9b8e9a0f1c | |||
28cd3e1f2f | |||
dc058d16df | |||
bbc9abc7b9 | |||
9aaf86a33c | |||
9d510ec8fb | |||
8b958c8947 | |||
3c240fc16b | |||
2a3491b98b | |||
378ee4632f | |||
19f99ff9d3 | |||
a9fcd4b6de | |||
e303968ca5 | |||
f85dc5bb18 | |||
562f5989e1 | |||
9455f40079 | |||
d59cc73470 | |||
7a66184a46 | |||
d8ec067675 | |||
c87c1df10a | |||
8fb0cecfbe | |||
61af45e837 | |||
4ee5340dd3 | |||
14ccedf1c1 | |||
8959f4405b | |||
e45bd16631 | |||
9b763cd34b | |||
00e8047fb7 | |||
4ca34b55de | |||
861fd29d45 | |||
f9af3e33b8 | |||
4b2c251e79 | |||
cc04bbf0f5 | |||
59eebbeb92 | |||
8f96346a6b | |||
f962dbb31c | |||
084d0f7c84 | |||
a2f1668490 | |||
68287b9bca | |||
8b96857d23 | |||
559be3ba6d | |||
1e147f21ed | |||
4d9b69469a | |||
f4932c3166 | |||
eed76d71dd | |||
f3acb6aa9a | |||
14328c7ad3 |
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
github: kevinveenbirkenbach
|
||||||
|
|
||||||
|
patreon: kevinveenbirkenbach
|
||||||
|
|
||||||
|
buy_me_a_coffee: kevinveenbirkenbach
|
||||||
|
|
||||||
|
custom: https://s.veen.world/paypaldonate
|
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
app/config.yaml
|
||||||
|
*__pycache__*
|
||||||
|
app/static/cache/*
|
||||||
|
.env
|
||||||
|
app/cypress/screenshots/*
|
BIN
512x512/logo.png
Before Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 98 KiB |
Before Width: | Height: | Size: 105 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 78 KiB |
14
Dockerfile
Normal 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
@@ -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
@@ -1 +1,161 @@
|
|||||||
# homepage.veen.world
|
# PortUI 🖥️✨
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||||
|
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||||
|
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||||
|
[](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/) (Kevin’s 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 Kevin’s 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 site’s 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
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
117
app/app.py
Normal 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
@@ -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
@@ -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 don’t need anything special
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
90
app/cypress/e2e/container.spec.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
85
app/cypress/e2e/fullscreen.spec.js
Normal 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=');
|
||||||
|
});
|
||||||
|
});
|
61
app/cypress/e2e/fullwidth.spec.js
Normal 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=');
|
||||||
|
});
|
||||||
|
});
|
46
app/cypress/e2e/iframe.spec.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// cypress/e2e/iframe.spec.js
|
||||||
|
|
||||||
|
describe('Iframe integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Visit the app’s 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');
|
||||||
|
});
|
||||||
|
});
|
130
app/cypress/e2e/menu.spec.js
Normal 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!');
|
||||||
|
});
|
||||||
|
});
|
130
app/cypress/e2e/modal.spec.js
Normal 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!');
|
||||||
|
});
|
||||||
|
});
|
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
130
app/cypress/e2e/tooltips.spec.js
Normal 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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"cypress": "^14.5.1"
|
||||||
|
}
|
||||||
|
}
|
3
app/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
requests
|
||||||
|
pyyaml
|
31
app/static/css/custom_scrollbar.css
Normal 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
@@ -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 that’s >= 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;
|
||||||
|
}
|
24
app/static/css/navigation.css
Normal 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
@@ -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
@@ -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 header’s 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;
|
42
app/static/js/fullwidth.js
Normal 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 full‐width 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
@@ -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 doesn’t 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 template’s 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
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
84
app/templates/moduls/base.html.j2
Normal 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>
|
34
app/templates/moduls/card.html.j2
Normal 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>
|
53
app/templates/moduls/modal.html.j2
Normal 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>
|
103
app/templates/moduls/navigation.html.j2
Normal 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>
|
12
app/templates/pages/index.html.j2
Normal 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 %}
|
45
app/utils/cache_manager.py
Normal 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}"
|
42
app/utils/compute_card_classes.py
Normal 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
|
147
app/utils/configuration_resolver.py
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
PORT=5001
|
||||||
|
FLASK_ENV=production
|
BIN
favicon.ico
Before Width: | Height: | Size: 41 KiB |
40
imprint.html
@@ -1,40 +0,0 @@
|
|||||||
<html>
|
|
||||||
<header>
|
|
||||||
<title>Impressum</title>
|
|
||||||
<meta charset="utf-8" >
|
|
||||||
</header>
|
|
||||||
<body>
|
|
||||||
<h1>Impressum</h1>
|
|
||||||
|
|
||||||
<h2>Angaben gemäß § 5 TMG</h2>
|
|
||||||
<p>Kevin Veen-Birkenbach<br />
|
|
||||||
Beratungs- und Coachingdienstleistungen<br />
|
|
||||||
Afrikanische Straß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äß § 27 a Umsatzsteuergesetz:<br />
|
|
||||||
23/569/00564</p>
|
|
||||||
|
|
||||||
<h2>Angaben zur Berufs­haftpflicht­versicherung</h2>
|
|
||||||
<p><strong>Name und Sitz des Versicherers:</strong><br />
|
|
||||||
Markel Insurance SE<br />
|
|
||||||
Sophienstr. 26<br />
|
|
||||||
80333 München<br />
|
|
||||||
Registergericht: Amtsgericht Mü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­streit­beilegung/Universal­schlichtungs­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>
|
|
251
index.html
@@ -1,251 +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>
|
|
||||||
<!-- Matomo -->
|
|
||||||
<script>
|
|
||||||
var _paq = window._paq = window._paq || [];
|
|
||||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
|
||||||
_paq.push(['trackPageView']);
|
|
||||||
_paq.push(['enableLinkTracking']);
|
|
||||||
(function() {
|
|
||||||
var u="//matomo.veen.world/";
|
|
||||||
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
|
||||||
_paq.push(['setSiteId', '1']);
|
|
||||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
|
||||||
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<!-- End Matomo Code -->
|
|
||||||
</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">
|
|
||||||
<h3>I'm on:</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.duolingo.com/profile/kevinbirkenbach">
|
|
||||||
<i class="fa-solid fa-language"></i> duolingo
|
|
||||||
</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">
|
|
||||||
<h3>I host:</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-link"></i>
|
|
||||||
cloud.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-link"></i>
|
|
||||||
git.veen.world
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://blog.veen.world">
|
|
||||||
<i class="fa-solid fa-link"></i>
|
|
||||||
blog.veen.world
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://mastodon.veen.world/@kevinveenbirkenbach">
|
|
||||||
<i class="fa-solid fa-link"></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-link"></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-link"></i>
|
|
||||||
peertube.veen.world
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h3>Contact</h3>
|
|
||||||
<div itemscope itemtype="http://schema.org/LocalBusiness">
|
|
||||||
<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>
|
|
||||||
<a href="imprint.html">
|
|
||||||
<i class="fa-solid fa-link"></i>
|
|
||||||
imprint
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
84
main.py
Executable 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
@@ -0,0 +1 @@
|
|||||||
|
python-dotenv
|