mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2025-09-10 19:47:10 +02:00
Compare commits
155 Commits
8f96346a6b
...
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 |
7
.github/FUNDING.yml
vendored
Normal file
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
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1 +1,5 @@
|
|||||||
app/static/logos/*
|
app/config.yaml
|
||||||
|
*__pycache__*
|
||||||
|
app/static/cache/*
|
||||||
|
.env
|
||||||
|
app/cypress/screenshots/*
|
12
Dockerfile
12
Dockerfile
@@ -1,18 +1,14 @@
|
|||||||
# Basis-Image für Python
|
# Base image for Python
|
||||||
FROM python:slim
|
FROM python:slim
|
||||||
|
|
||||||
# Arbeitsverzeichnis festlegen
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Abhängigkeiten kopieren und installieren
|
# Copy and install dependencies
|
||||||
COPY app/requirements.txt requirements.txt
|
COPY app/requirements.txt requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Anwendungscode kopieren
|
# Copy application code
|
||||||
COPY app/ .
|
COPY app/ .
|
||||||
|
|
||||||
# Port freigeben
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Startbefehl
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
|
82
Makefile
Normal file
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
162
README.md
@@ -1,9 +1,161 @@
|
|||||||
# homepage.veen.world
|
# PortUI 🖥️✨
|
||||||
|
|
||||||
docker build -t flask-app .
|
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||||
|
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||||
|
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||||
|
[](https://s.veen.world/paypaldonate)
|
||||||
|
|
||||||
docker run -d -p 5000:5000 --name landingpage flask-app
|
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||||
|
|
||||||
http://127.0.0.1:5000
|
> 🚀 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)
|
||||||
|
|
||||||
sudo docker run -d -p 5000:5000 --name landingpage -v $(pwd)/app/:/app -e FLASK_APP=app.py -e FLASK_ENV=development flask-app
|
---
|
||||||
|
|
||||||
|
## ✨ 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
2
app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
149
app/app.py
149
app/app.py
@@ -1,70 +1,117 @@
|
|||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
|
import yaml
|
||||||
import requests
|
import requests
|
||||||
import hashlib
|
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}")
|
||||||
|
|
||||||
def cache_icon(icon_url, cache_dir="static/logos"):
|
from flask import Flask, render_template, current_app
|
||||||
"""Lädt ein Icon herunter und speichert es lokal, wenn es nicht existiert. Fügt einen Hash hinzu."""
|
from markupsafe import Markup
|
||||||
# Erstelle das Verzeichnis, falls es nicht existiert
|
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Generiere einen 8-Zeichen-Hash basierend auf der URL
|
|
||||||
hash_object = hashlib.blake2s(icon_url.encode('utf-8'), digest_size=8)
|
|
||||||
hash_suffix = hash_object.hexdigest()
|
|
||||||
|
|
||||||
# Erstelle den Dateinamen mit Hash
|
|
||||||
base_name = icon_url.split("/")[-2]
|
|
||||||
filename = f"{base_name}_{hash_suffix}.png"
|
|
||||||
full_path = os.path.join(cache_dir, filename)
|
|
||||||
|
|
||||||
# Wenn die Datei existiert, überspringe den Download
|
|
||||||
if os.path.exists(full_path):
|
|
||||||
return full_path
|
|
||||||
|
|
||||||
# Lade die Datei herunter
|
# Initialize the CacheManager
|
||||||
response = requests.get(icon_url, stream=True)
|
cache_manager = CacheManager()
|
||||||
if response.status_code == 200:
|
|
||||||
with open(full_path, "wb") as f:
|
# Clear cache on startup
|
||||||
for chunk in response.iter_content(1024):
|
cache_manager.clear_cache()
|
||||||
f.write(chunk)
|
|
||||||
return full_path
|
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__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Hole die Umgebungsvariable FLASK_ENV oder setze einen Standardwert
|
# Load configuration and cache assets on startup
|
||||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
load_config(app)
|
||||||
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
config_data = None # Globale Variable für die Konfiguration
|
@app.context_processor
|
||||||
|
def utility_processor():
|
||||||
def load_config():
|
def include_svg(path):
|
||||||
"""Lädt die Konfiguration aus der JSON-Datei."""
|
full_path = os.path.join(current_app.root_path, 'static', path)
|
||||||
with open("config.json", "r") as config_file:
|
try:
|
||||||
return json.load(config_file)
|
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
|
@app.before_request
|
||||||
def reload_config_in_dev():
|
def reload_config_in_dev():
|
||||||
"""Lädt die Datei bei jedem Request neu im Dev-Modus."""
|
"""Reload config and recache icons before each request in development mode."""
|
||||||
global config_data
|
if FLASK_ENV == "development":
|
||||||
if FLASK_ENV == "development" or config_data is None:
|
load_config(app)
|
||||||
config_data = load_config()
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
# Cachen der Icons
|
|
||||||
for card in config_data["cards"]:
|
|
||||||
card["icon"] = cache_icon(card["icon"])
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template("pages/index.html.j2", cards=config_data.get("cards", []), networks=config_data.get("networks", []), company=config_data["company"])
|
"""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")
|
||||||
|
|
||||||
@app.route('/imprint')
|
return render_template(
|
||||||
def imprint():
|
"pages/index.html.j2",
|
||||||
return render_template('pages/imprint.html.j2')
|
cards=cards,
|
||||||
|
company=app.config["company"],
|
||||||
@app.route('/agb')
|
navigation=app.config["navigation"],
|
||||||
def agb():
|
platform=app.config["platform"],
|
||||||
return render_template('pages/agb.html.j2')
|
lg_classes=lg_classes,
|
||||||
|
md_classes=md_classes,
|
||||||
|
apod_bg=apod_bg
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=5000)
|
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=FLASK_PORT)
|
||||||
|
112
app/config.json
112
app/config.json
@@ -1,112 +0,0 @@
|
|||||||
{
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.agile-coach.world",
|
|
||||||
"link_text": "www.agile-coach.world"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.personalcoach.berlin",
|
|
||||||
"link_text": "www.personalcoach.berlin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.yachtmaster.world",
|
|
||||||
"link_text": "www.yachtmaster.world"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.crossdomain.consulting/",
|
|
||||||
"link_text": "www.crossdomain.consulting"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.cybermaster.space",
|
|
||||||
"link_text": "www.cybermaster.space"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://promptmaster.nexus",
|
|
||||||
"link_text": "www.promptmaster.nexus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.mediator.veen.world",
|
|
||||||
"link_text": "www.mediator.veen.world"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "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.",
|
|
||||||
"link": "https://www.hypno.veen.world",
|
|
||||||
"link_text": "www.hypno.veen.world"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "https://cloud.veen.world/s/logo_skydiver_512x512/download",
|
|
||||||
"title": "Aerospace Consultant",
|
|
||||||
"text": "As an Aerospace Consultant with aviation credentials, including a Sport Pilot License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate I deliver expert consulting services. Currently training for my Private Pilot License, I specialize in guiding clients through aviation regulations, safety standards, and operational efficiency.",
|
|
||||||
"link": null,
|
|
||||||
"link_text": "Website under construction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "https://cloud.veen.world/s/logo_hunter_512x512/download",
|
|
||||||
"title": "Wildlife Expert",
|
|
||||||
"text": "As a certified hunter and wildlife coach, I offer educational programs, nature walks, survival trainings, and photo expeditions, merging ecological knowledge with nature respect. My goal is to foster sustainable conservation and enhance appreciation for the natural world through responsible practices.",
|
|
||||||
"link": null,
|
|
||||||
"link_text": "Website under construction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "https://cloud.veen.world/s/logo_diver_512x512/download",
|
|
||||||
"title": "Master Diver",
|
|
||||||
"text": "As a certified master diver with trainings in various specialties, I offer diving instruction, underwater photography, and guided dive tours. My experience ensures safe and enriching underwater adventures, highlighting marine conservation and the wonders of aquatic ecosystems.",
|
|
||||||
"link": null,
|
|
||||||
"link_text": "Website under construction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "https://cloud.veen.world/s/logo_massage_therapist_512x512/download",
|
|
||||||
"title": "Massage Therapist",
|
|
||||||
"text": "Certified in Tantra Massage, I offer unique full-body rituals to awaken senses and harmonize body and mind. My sessions, a blend of ancient Tantra and modern relaxation, focus on energy flow, personal growth, and spiritual awakening.",
|
|
||||||
"link": null,
|
|
||||||
"link_text": "Website under construction"
|
|
||||||
}
|
|
||||||
|
|
||||||
],
|
|
||||||
"networks": [
|
|
||||||
{
|
|
||||||
"name": "GitHub",
|
|
||||||
"icon": "bi bi-github",
|
|
||||||
"link": "https://github.com/kevinveenbirkenbach"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Instagram",
|
|
||||||
"icon": "fa-brands fa-instagram",
|
|
||||||
"link": "https://www.instagram.com/kevinveenbirkenbach/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"company": {
|
|
||||||
"titel": "Kevin Veen-Birkenbach",
|
|
||||||
"subtitel": "Consulting and Coaching Solutions",
|
|
||||||
"logo": "https://cloud.veen.world/s/logo_face_512x512/download",
|
|
||||||
"address": {
|
|
||||||
"street": "Afrikanische Straße 43",
|
|
||||||
"postal_code": "DE-13351",
|
|
||||||
"city": "Berlin",
|
|
||||||
"country": "Germany"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
683
app/config.sample.yaml
Normal file
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
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
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
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
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
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
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
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
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
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
5
app/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"cypress": "^14.5.1"
|
||||||
|
}
|
||||||
|
}
|
@@ -1,2 +1,3 @@
|
|||||||
flask
|
flask
|
||||||
requests
|
requests
|
||||||
|
pyyaml
|
31
app/static/css/custom_scrollbar.css
Normal file
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;
|
||||||
|
}
|
@@ -1,67 +1,199 @@
|
|||||||
|
@import url("navigation.css");
|
||||||
|
|
||||||
|
/* General link styles */
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
.header img {
|
|
||||||
float: right;
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
/* Header styles */
|
||||||
position: relative;
|
.header img {
|
||||||
}
|
float: right;
|
||||||
.equal-height {
|
width: 100px;
|
||||||
display: flex;
|
height: 100px;
|
||||||
flex: 1;
|
}
|
||||||
}
|
|
||||||
.card-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center; /* Zentriert die Inhalte horizontal */
|
|
||||||
text-align: center; /* Zentriert den Text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
.header h1 {
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: center; /* Zentriert das Icon horizontal */
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.card-text,
|
|
||||||
.card ul {
|
|
||||||
text-align: left; /* Stellt sicher, dass der Text linksbündig ist */
|
|
||||||
}
|
|
||||||
|
|
||||||
.card{
|
/* Equal-height container using flexbox */
|
||||||
flex: 1; /* Stellt sicher, dass die Karten die ganze Höhe ihrer Container ausfüllen */
|
.equal-height {
|
||||||
border-width: 3px;
|
display: flex;
|
||||||
/*border-color: #000000;*/
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3.card-title{
|
/* Subtle shadow effect */
|
||||||
font-size: 1.3em;
|
.navbar, .card, .dropdown-menu {
|
||||||
}
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.card .stretched-link{
|
/* Card styles */
|
||||||
font-size: 0.7em;
|
.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-column{
|
.card {
|
||||||
padding-top: 12px;
|
transition: background-color 1s ease, transform 1s ease;
|
||||||
padding-bottom: 12px;
|
transition: color 1s ease, transform 1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3.footer-title{
|
.card:hover {
|
||||||
font-size: 1.3em;
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.card-body {
|
||||||
margin-top: 12px;
|
display: flex;
|
||||||
text-align: center;
|
flex-direction: column;
|
||||||
font-size: 0.7em;
|
align-items: center; /* Center content horizontally */
|
||||||
}
|
text-align: center; /* Center text alignment */
|
||||||
|
}
|
||||||
|
|
||||||
.footer p, h3{
|
.card-icon {
|
||||||
margin: 0px;
|
display: flex;
|
||||||
padding: 0px;
|
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
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;
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 41 KiB |
108
app/static/js/container.js
Normal file
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
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
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
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
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
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
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);
|
||||||
|
});
|
||||||
|
});
|
@@ -1,35 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{company.titel}}</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>
|
|
||||||
<link rel="stylesheet" href="static/css/default.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header class="header">
|
|
||||||
<img src="{{company.logo}}" alt="logo"/>
|
|
||||||
<h1>{{company.titel}}</h1>
|
|
||||||
<h2>{{company.subtitel}}</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<footer class="footer" 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="/imprint"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@@ -1,13 +0,0 @@
|
|||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="{{ card.icon }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">{{ card.title }}</h3>
|
|
||||||
<p class="card-text">{{ card.text }}</p>
|
|
||||||
<a href="{{ card.link }}" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> {{ card.link_text }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@@ -1,474 +0,0 @@
|
|||||||
<html>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<img src="512x512/logo_face.png" alt="logo"/>
|
|
||||||
<h1>Kevin Veen-Birkenbach</h1>
|
|
||||||
<h2>Consulting and Coaching Solutions</h2>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="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 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.</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="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="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">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.</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="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_yachtmaster.png" alt="Yachtmaster" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Yachtmaster</h3>
|
|
||||||
<p class="card-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.</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="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_polymath.png" alt="Cross Domain Consultant" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Polymath</h3>
|
|
||||||
<p class="card-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.</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="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_cybermaster.png" alt="Cyber Master" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Cybermaster</h3>
|
|
||||||
<p class="card-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.</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 class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_prompt_master.png" alt="Prompt Master" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Prompt Engineer</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<a href="https://promptmaster.nexus" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.promptmaster.nexus</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_mediator.png" alt="Mediator" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Mediator</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<a href="https://www.mediator.veen.world" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.mediator.veen.world</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_hypnotherapist.png" alt="Hypnotherapist" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Hypnotherapist</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<a href="https://www.hypno.veen.world" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> www.hypno.veen.world</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_skydiver.png" alt="Skydiver" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Aerospace Consultant</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<i class="fa-solid fa-hourglass"></i> Website under construction.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_hunter.png" alt="Hunter" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Wildlife Expert</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<i class="fa-solid fa-hourglass"></i> Website under construction.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_diver.png" alt="Diver" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Master Diver</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<i class="fa-solid fa-hourglass"></i> Website under construction.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
|
||||||
<div class="card h-100 d-flex flex-column">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="card-img-top">
|
|
||||||
<img src="512x512/logo_massage_therapist.png" alt="Massgage Therapist" style="width: 100px; height: auto;">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h3 class="card-title">Massage Therapist</h3>
|
|
||||||
<p class="card-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.</p>
|
|
||||||
<i class="fa-solid fa-hourglass"></i> Website under construction.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Networks</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
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://www.instagram.com/kevinveenbirkenbach/">
|
|
||||||
<i class="fa-brands fa-instagram"></i>
|
|
||||||
Instagram
|
|
||||||
</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
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://www.linkedin.com/in/kevinveenbirkenbach">
|
|
||||||
<i class="bi bi-linkedin"></i>
|
|
||||||
LinkedIn
|
|
||||||
</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>
|
|
||||||
<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://open.spotify.com/user/31vebfzbjf3p7oualis76qfpr5ty">
|
|
||||||
<i class="fa-brands fa-spotify"></i> Spotify
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Fediverse</h3>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://microblog.veen.world/@kevinveenbirkenbach">
|
|
||||||
<i class="fa-brands fa-mastodon"></i>
|
|
||||||
Microblog
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://picture.veen.world/kevinveenbirkenbach">
|
|
||||||
<i class="fa-solid fa-camera"></i>
|
|
||||||
Pictures
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://video.veen.world/a/kevinveenbirkenbach">
|
|
||||||
<i class="fa-solid fa-video"></i>
|
|
||||||
Videos
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://blog.veen.world">
|
|
||||||
<i class="fa-solid fa-blog"></i>
|
|
||||||
Blog
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://git.veen.world/kevinveenbirkenbach" class="link">
|
|
||||||
<i class="fa-solid fa-code"></i>
|
|
||||||
Code
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<s><a rel="me" href="https://music.veen.world">
|
|
||||||
<i class="fa-solid fa-music"></i>
|
|
||||||
Music
|
|
||||||
</a></s>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://forum.veen.world/u/kevinveenbirkenbach">
|
|
||||||
<i class="fa-brands fa-discourse"></i> Forum
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Engagement</h3>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://newsletter.veen.world/subscription/form" class="link">
|
|
||||||
<i class="fa-solid fa-envelope-open-text"></i>
|
|
||||||
Newsletter
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://meet.veen.world/" class="link">
|
|
||||||
<i class="fa-solid fa-video"></i>
|
|
||||||
Summits
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://mail.veen.world/">
|
|
||||||
<i class="fa-solid fa-envelope"></i>
|
|
||||||
Mail
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://academy.veen.world/">
|
|
||||||
<i class="fa-solid fa-graduation-cap"></i>
|
|
||||||
Academy
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://element.veen.world/">
|
|
||||||
<i class="fa-solid fa-comment"></i> Chat
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/admin/">
|
|
||||||
<i class="bi bi-link"></i>
|
|
||||||
Links
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://patreon.com/kevinveenbirkenbach">
|
|
||||||
<i class="fa-brands fa-patreon"></i> Patreon
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Workflow</h3>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://project.veen.world/" class="link">
|
|
||||||
<i class="fa-solid fa-chart-line"></i>
|
|
||||||
Projects
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://kanban.veen.world/" class="link">
|
|
||||||
<i class="bi bi-clipboard2-check-fill"></i>
|
|
||||||
Kanban
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://matomo.veen.world/" class="link">
|
|
||||||
<i class="fa-solid fa-chart-simple"></i>
|
|
||||||
Matomo
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://baserow.veen.world/" class="link">
|
|
||||||
<i class="fa-solid fa-table"></i>
|
|
||||||
Baserow
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<s><a rel="me" href="https://finance.veen.world/">
|
|
||||||
<i class="bi bi-currency-exchange"></i>
|
|
||||||
Controlling
|
|
||||||
</a></s>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<s><a rel="me" href="https://tickets.veen.world/">
|
|
||||||
<i class="bi bi-ticket-perforated"></i>
|
|
||||||
Tickets
|
|
||||||
</a></s>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://cloud.veen.world/u/kevinveenbirkenbach" class="link">
|
|
||||||
<i class="fa-solid fa-cloud"></i>
|
|
||||||
Cloud
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Logbooks</h3>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/skydiverlog" class="link">
|
|
||||||
<i class="fa-solid fa-parachute-box"></i>
|
|
||||||
Skydiver
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/meilenbuch" class="link">
|
|
||||||
<i class="fa-solid fa-sailboat"></i>
|
|
||||||
Skipper
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/diverlog" class="link">
|
|
||||||
<i class="fa-solid fa-fish"></i>
|
|
||||||
Diver
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/pilotlog">
|
|
||||||
<i class="fa-solid fa-plane"></i>
|
|
||||||
Pilote
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/naturejournal">
|
|
||||||
<i class="fa-solid fa-tree"></i>
|
|
||||||
Nature
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/garmin">
|
|
||||||
<i class="fa-solid fa-person-running"></i>
|
|
||||||
Garmin
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/eversports">
|
|
||||||
<i class="fa-solid fa-dumbbell"></i>
|
|
||||||
Eversports
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-4 col-sm-6 col-12 mt-4">
|
|
||||||
<h3 class="footer-title">Contact</h3>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="mailto:kevin@veen.world">
|
|
||||||
<i class="fa-solid fa-envelope"></i>
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a alt="@kevinveenbirkenbach:veen.world" title="@kevinveenbirkenbach:veen.world" href="#" onclick="openPopup()">
|
|
||||||
<i class="bi bi-chat-left-text-fill"></i>
|
|
||||||
Matrix
|
|
||||||
</a>
|
|
||||||
<script>
|
|
||||||
function openPopup() {
|
|
||||||
alert("Matrix: @kevinveenbirkenbach:veen.world");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="tel:+491781798023">
|
|
||||||
<i class="fa-solid fa-phone"></i>
|
|
||||||
Mobile
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://s.veen.world/pgp">
|
|
||||||
<i class="fa-solid fa-key"></i>
|
|
||||||
PGP
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="tel:+491781798023" class="link">
|
|
||||||
<i class="fa-brands fa-signal-messenger"></i>
|
|
||||||
Signal
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://t.me/kevinveenbirkenbach" class="link">
|
|
||||||
<i class="fa-brands fa-telegram"></i>
|
|
||||||
Telegram
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item">
|
|
||||||
<a rel="me" href="https://wa.me/491781798023" class="link">
|
|
||||||
<i class="fa-brands fa-whatsapp"></i>
|
|
||||||
Whatsapp
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div itemscope itemtype="http://schema.org/LocalBusiness" class="small">
|
|
||||||
|
|
||||||
<span itemprop="name">
|
|
||||||
Kevin Veen-Birkenbach <br />
|
|
||||||
Beratungs- und Coachingdienstleistungen
|
|
||||||
</span>
|
|
||||||
<p>
|
|
||||||
<i class="fa-solid fa-location-dot"></i>
|
|
||||||
<span itemprop="address">Afrikanische Straße 43, DE-13351 Berlin, Germany</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a rel="me" href="imprint.html">
|
|
||||||
<i class="fa-solid fa-scale-balanced"></i> Imprint
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
84
app/templates/moduls/base.html.j2
Normal file
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
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
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
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>
|
@@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>General Terms and Conditions</title>
|
|
||||||
<style>
|
|
||||||
body {font-family: Arial, sans-serif; line-height: 1.6;}
|
|
||||||
h1, h2 {color: #333;}
|
|
||||||
.container {width: 80%; margin: 0 auto; padding: 20px;}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>General Terms and Conditions</h1>
|
|
||||||
|
|
||||||
<h2>1. Scope of Services</h2>
|
|
||||||
<p>Kevin Veen-Birkenbach, operating under the business name "Beratungs- und Coachingdienstleistungen," provides various services including consulting, coaching, educational services, freelance assignments, and project-based work.</p>
|
|
||||||
|
|
||||||
<h2>2. Payment Terms</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Payment is due upon booking any service.</li>
|
|
||||||
<li>In case of cancellation more than 30 days before the scheduled service, the client is entitled to a full refund.</li>
|
|
||||||
<li>If cancellation occurs less than 30 days but more than 14 days before the service, 50% of the total fee will be retained.</li>
|
|
||||||
<li>For cancellations less than 14 days prior to the service, 100% of the total fee will be retained.</li>
|
|
||||||
<li>If the service is cancelled by Kevin Veen-Birkenbach for any reason, the client is entitled to a full refund.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>3. Contact Information</h2>
|
|
||||||
<p>Kevin Veen-Birkenbach<br>
|
|
||||||
Beratungs- und Coachingdienstleistungen<br>
|
|
||||||
Afrikanische Straße 43, 13351 Berlin, Germany<br>
|
|
||||||
Phone: +491781798023<br>
|
|
||||||
Email: <a href="mailto:kevin@veen.world">kevin@veen.world</a></p>
|
|
||||||
|
|
||||||
<h2>4. Tax Identification</h2>
|
|
||||||
<p>VAT ID: 23/569/00564</p>
|
|
||||||
|
|
||||||
<h2>5. Amendments and Validity</h2>
|
|
||||||
<p>These terms and conditions are subject to change. The version valid at the time of booking will apply.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</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>
|
|
@@ -1,23 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "moduls/base.html.j2" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for card in cards %}
|
{% for card in cards %}
|
||||||
{% include "card.html.j2" %}
|
{% set index = loop.index0 %}
|
||||||
|
{% set lg_class = lg_classes[index] %}
|
||||||
|
{% set md_class = md_classes[index] %}
|
||||||
|
{% include "moduls/card.html.j2" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<h3>Networks</h3>
|
|
||||||
<ul>
|
|
||||||
{% for network in networks %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ network.link }}">
|
|
||||||
<i class="{{ network.icon }}"></i> {{ network.name }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
45
app/utils/cache_manager.py
Normal file
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
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
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
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
2
env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PORT=5001
|
||||||
|
FLASK_ENV=production
|
84
main.py
Executable file
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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-dotenv
|
Reference in New Issue
Block a user