Compare commits

...

47 Commits

Author SHA1 Message Date
f8c2b4236b Added d-flex to place logo next to brand 2025-07-21 14:24:28 +02:00
dc2626e020 Added test for log 2025-07-21 12:18:07 +02:00
46b0b744ca Added logo to navbar when in fullscreen 2025-07-21 11:39:59 +02:00
5f2e7ef696 Changed pkgmgr commands 2025-07-12 18:54:42 +02:00
152a85bfb8 Merge branch 'main' of github.com:kevinveenbirkenbach/portfolio 2025-07-12 18:53:03 +02:00
fdfe301868 Renamed PortWebUI to PortUI 2025-07-12 18:52:51 +02:00
cbfe1ed8ae Update README.md
Solved another layout bug
2025-07-12 18:27:47 +02:00
9470162236 Solved formatation bug 2025-07-12 18:26:51 +02:00
6a57fa1e00 Optimized README.md 2025-07-12 18:24:49 +02:00
ab67fc0b29 Renamed portfolio to PortWebUI 2025-07-12 18:22:19 +02:00
e18566d801 Solved some bugs 2025-07-09 22:20:58 +02:00
7bc0f32145 Added cypress tests 2025-07-08 17:16:57 +02:00
6ed3e60dd0 Solved 2tap fullscreen hight bug 2025-07-08 14:39:13 +02:00
ab8ea0dbd6 Added iframe observer 2025-07-07 23:40:35 +02:00
b0446dcd29 Added include svgs 2025-07-07 19:14:29 +02:00
55d309b2d7 Changed fade between html iframe animation 2025-07-07 15:37:24 +02:00
d99a8c8452 Added restore functionality to small logo 2025-07-07 15:06:36 +02:00
3f6a195ecb Optimizid hover 2025-07-07 13:37:02 +02:00
430ea4a120 Solved loading bug 2025-07-07 13:19:49 +02:00
cc0fc9b77f Replaced object by svg 2025-07-07 12:46:56 +02:00
9ada9acb3a Implemented SVG handling 2025-07-07 12:40:25 +02:00
246ef1b059 Added backup logik for missing images 2025-07-07 08:57:30 +02:00
6572a39d48 Added hover effects to cards 2025-07-06 22:25:22 +02:00
a80262c0d4 Solved container height bug 2025-07-06 22:18:28 +02:00
531c2295bd Added header/footer animation 2025-07-06 18:14:42 +02:00
0640ec6439 Added animation for fullscreen log 2025-07-06 17:46:51 +02:00
a7eb14046f Added fullwidth animation 2025-07-06 17:35:40 +02:00
539580ad09 Added missing enterfullscreen function 2025-07-06 17:13:15 +02:00
faf5bd1e8c Solved iframe margin bug 2025-07-06 10:55:12 +02:00
97378422bd Solved more CSS bugs 2025-07-05 21:07:31 +02:00
2632c21de3 Removed unnecessary log messages 2025-07-05 20:33:08 +02:00
64db9a4e6a Implemented Nasa Picture of the day 2025-07-05 20:08:00 +02:00
d0f8d7d172 Added logo for small screen 2025-07-05 18:54:18 +02:00
20b6c731b8 Added onclick functionality for menu items 2025-07-05 18:32:26 +02:00
2f63009c31 Implemented full width function 2025-07-05 18:00:23 +02:00
f0d4206731 Finished resize implementation for iframe 2025-07-05 16:53:25 +02:00
b8aad8b695 Removed non functional resize code 2025-07-05 14:39:18 +02:00
697696347f finished fullscreen base implementation 2025-07-05 14:20:52 +02:00
d6389157ec Added fullscreen mode 2025-07-05 13:30:25 +02:00
25dbc3f331 Added correct iframe size loading 2025-07-05 13:17:38 +02:00
bb8799eb8a Added functionality for iframe url 2025-07-05 11:54:20 +02:00
86fd72b623 Solved wrong environment bug 2025-07-05 11:41:18 +02:00
9c24a8658f Solved other port mapping issues 2025-07-05 11:06:42 +02:00
5fc19f6ccb Solved other port bugs 2025-07-05 10:55:32 +02:00
35bfeeb51e Added correct env path import 2025-07-05 10:12:49 +02:00
dfbc840c69 Optimized default port 2025-07-05 09:40:11 +02:00
1bea9703ea Added port via env 2025-07-05 09:32:07 +02:00
31 changed files with 1648 additions and 582 deletions

2
.gitignore vendored
View File

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

View File

@@ -11,8 +11,4 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ .
# Expose port
EXPOSE 5000
# Start command
CMD ["python", "app.py"]

82
Makefile Normal file
View File

@@ -0,0 +1,82 @@
# Load environment variables from .env
ifneq (,$(wildcard .env))
include .env
# Export variables defined in .env
export $(shell sed 's/=.*//' .env)
endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
# Default port (can be overridden with PORT env var)
.PHONY: build
build:
# Build the Docker image.
docker build -t application-portfolio .
.PHONY: up
up:
# Start the application using docker-compose with build.
docker-compose up -d --build
.PHONY: down
down:
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
- docker stop portfolio || true
- docker rm portfolio || true
- docker-compose down
.PHONY: run-dev
run-dev:
# Run the container in development mode (hot-reload).
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
-v $(PWD)/app/:/app \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
application-portfolio
.PHONY: run-prod
run-prod:
# Run the container in production mode.
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
application-portfolio
.PHONY: logs
logs:
# Display the logs of the 'portfolio' container.
docker logs -f portfolio
.PHONY: dev
dev:
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod:
# Start the application in production mode using docker-compose (with build).
docker-compose up -d --build
.PHONY: cleanup
cleanup:
# Remove all stopped Docker containers to reclaim space.
docker container prune -f
.PHONY: delete
delete:
# Force remove the 'portfolio' container if it exists.
- docker rm -f portfolio
.PHONY: browse
browse:
# Open the application in the browser at http://localhost:$(PORT)
chromium http://localhost:$(PORT)
npm-install:
cd app && npm install
test: npm-install
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"

147
README.md
View File

@@ -1,86 +1,98 @@
# Portfolio CMS: Flask-based Portfolio Management 🚀
# PortUI 🖥️✨
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
This software allows individuals and institutions to set up an easy portfolio/landingpage/homepage to showcase their projects and online presence. It is highly customizable via a YAML configuration file.
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
## Features ✨
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)
- **Dynamic Navigation:** Easily create dropdown menus and nested links.
- **Customizable Cards:** Showcase your skills, projects, or services.
- **Cache Management:** Optimize your assets with automatic caching.
- **Responsive Design:** Beautiful on any device with Bootstrap.
- **Easy Configuration:** Update content using a YAML file.
- **Command Line Interface:** Manage Docker containers with the `portfolio` CLI.
---
## Access 🌐
## ✨ Key Features
### Local Access
Access the application locally at [http://127.0.0.1:5000](http://127.0.0.1:5000).
- **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.
## Getting Started 🏁
---
### Prerequisites 📋
## 🌐 Quick Access
- Docker and Docker Compose installed on your system.
- Basic knowledge of Python and YAML for configuration.
- **Local Preview:**
[http://127.0.0.1:5000](http://127.0.0.1:5000)
### Installation 🛠️
---
#### Installation via git clone
## 🏁 Getting Started
1. **Clone the repository:**
### 🔧 Prerequisites
- Docker & Docker Compose
- Basic Python & YAML knowledge
### 🛠️ Installation via Git
1. **Clone & enter repo**
```bash
git clone <repository_url>
cd <repository_directory>
```
2. **Update the configuration:**
Create a `config.yaml` file. You can use `config.sample.yaml` as an example (see below for details on the configuration).
2. **Configure**
Copy `config.sample.yaml` → `config.yaml` & customize.
3. **Build & run**
3. **Build and run the Docker container:**
```bash
docker-compose up --build
```
4. **Browse**
Open [http://localhost:5000](http://localhost:5000)
4. **Access your portfolio:**
Open your browser and navigate to [http://localhost:5000](http://localhost:5000).
### Installation via Kevin's Package Manager
You can install the `portfolio` CLI using [Kevin's package manager](https://github.com/kevinveenbirkenbach/package-manager). Simply run:
### 📦 Installation via Kevins Package Manager
```bash
pkgmgr install portfolio
pkgmgr install portui
```
This will install the CLI tool, making it available system-wide.
Once installed, the `portui` CLI is available system-wide.
### Available Commands
---
After installation, you can access the help information for the CLI by running:
## 🖥️ CLI Commands
```bash
portfolio --help
portui --help
```
This command displays detailed instructions on how to use the following commands:
* `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
- **build:** Build the Docker image for the portfolio application.
- **up:** Start the application using docker-compose (with build).
- **down:** Stop and remove the Docker container.
- **run-dev:** Run the container in development mode with hot-reloading.
- **run-prod:** Run the container in production mode.
- **logs:** Display the logs of the running container.
- **dev:** Start the application in development mode using docker-compose.
- **prod:** Start the application in production mode using docker-compose.
- **cleanup:** Remove all stopped Docker containers to clean up your Docker environment.
---
## YAML Configuration Guide 🔧
## 🔧 YAML Configuration Guide
The portfolio is powered by a YAML configuration file (`config.yaml`). This file allows you to define the structure and content of your site, including cards, navigation, and company details.
### YAML Configuration Example 📄
Define your sites structure in `config.yaml`:
```yaml
accounts:
@@ -93,14 +105,9 @@ accounts:
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.
description: Follow me on Mastodon.
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
@@ -112,9 +119,10 @@ accounts:
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:
titel: Kevin Veen-Birkenbach
subtitel: Consulting and Coaching Solutions
title: Kevin Veen-Birkenbach
subtitle: Consulting & Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_face_512x512/download
favicon:
@@ -127,26 +135,27 @@ company:
imprint_url: https://s.veen.world/imprint
```
### Understanding the `children` Key 🔍
* **`children`** enables multi-level menus.
* **`link`** references other YAML paths to avoid duplication.
The `children` key allows hierarchical nesting of elements. Each child can itself have children, enabling the creation of multi-level navigation menus or grouped content.
---
### Understanding the `link` Key 🔗
## 🚢 Production Deployment
The `link` key allows you to reference another part of the YAML configuration by its path, which helps avoid duplication and maintain consistency.
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
## Deployment 🚢
---
For production deployment, ensure to:
## 📜 License
- Use a reverse proxy like NGINX or Apache.
- Secure your site with SSL/TLS.
- Use a production-ready database if required.
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
## License 📜
---
This project is licensed under the GNU Affero General Public License Version 3. See the [LICENSE](./LICENSE) file for details.
## ✍️ Author
## Author ✍️
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
This software was created by [Kevin Veen-Birkenbach](https://www.veen.world/).
Enjoy building your portfolio! 🌟

2
app/.gitignore vendored Normal file
View File

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

View File

@@ -1,9 +1,18 @@
import os
from flask import Flask, render_template
import yaml
import requests
from utils.configuration_resolver import ConfigurationResolver
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
import logging
logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_PORT = int(os.getenv("PORT", 5000))
print(f"🔧 Starting app on port {FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
from flask import Flask, render_template, current_app
from markupsafe import Markup
# Initialize the CacheManager
cache_manager = CacheManager()
@@ -16,23 +25,37 @@ def load_config(app):
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."""
"""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"):
icon["cache"] = cache_manager.cache_file(icon["source"])
cached = cache_manager.cache_file(icon["source"])
# Fallback: wenn cache_file None liefert, nutze weiterhin source
icon["cache"] = cached or icon["source"]
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"])
app.config["platform"]["favicon"]["cache"] = cache_manager.cache_file(app.config["platform"]["favicon"]["source"])
app.config["platform"]["logo"]["cache"] = cache_manager.cache_file(app.config["platform"]["logo"]["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"]
# Get the environment variable FLASK_ENV or set a default value
FLASK_ENV = os.getenv("FLASK_ENV", "production")
# Initialize Flask app
app = Flask(__name__)
@@ -41,6 +64,18 @@ app = Flask(__name__)
load_config(app)
cache_icons_and_logos(app)
@app.context_processor
def utility_processor():
def include_svg(path):
full_path = os.path.join(current_app.root_path, 'static', path)
try:
with open(full_path, 'r', encoding='utf-8') as f:
svg = f.read()
return Markup(svg)
except IOError:
return Markup(f'<!-- SVG not found: {path} -->')
return dict(include_svg=include_svg)
@app.before_request
def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode."""
@@ -53,6 +88,20 @@ def index():
"""Render the main index page."""
cards = app.config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
# fetch NASA APOD URL only if key present
apod_bg = None
api_key = app.config.get("NASA_API_KEY")
if api_key:
resp = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": api_key}
)
if resp.ok:
data = resp.json()
# only use if it's an image
if data.get("media_type") == "image":
apod_bg = data.get("url")
return render_template(
"pages/index.html.j2",
cards=cards,
@@ -60,8 +109,9 @@ def index():
navigation=app.config["navigation"],
platform=app.config["platform"],
lg_classes=lg_classes,
md_classes=md_classes
md_classes=md_classes,
apod_bg=apod_bg
)
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)

View File

@@ -1,5 +1,6 @@
---
accounts:
nasa_api_key: YOUR_REAL_KEY_HERE
name: Online Presence
description: Discover my online presence.
icon:
@@ -647,4 +648,36 @@ navigation:
class: fa-solid fa-scale-balanced
url: https://s.veen.world/imprint
iframe: true
- name: Settings
description: Application settings
icon:
class: fa-solid fa-cog
children:
- name: Toggle Fullscreen
description: Enter or exit fullscreen mode
icon:
class: fa-solid fa-expand-arrows-alt
onclick: "toggleFullscreen()"
- name: Toggle Full Width
description: Switch between normal and full-width layout
icon:
class: fa-solid fa-arrows-left-right
onclick: "setFullWidth(!initFullWidthFromUrl())"
- name: Open in new tab
description: Open the currently embedded iframe URL in a fresh browser tab
icon:
class: fa-solid fa-up-right-from-square
onclick: openIframeInNewTab()
- name: Print
description: Print the current view
icon:
class: fa-solid fa-print
onclick: window.print()
- name: Zoom +
icon:
class: fa-solid fa-search-plus
onclick: zoomPage(1.1)
- name: Zoom
icon:
class: fa-solid fa-search-minus
onclick: zoomPage(0.9)

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

5
app/package.json Normal file
View File

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

View File

@@ -38,6 +38,18 @@ a {
background-color: #f9f9f9;
}
.card {
transition: background-color 1s ease, transform 1s ease;
transition: color 1s ease, transform 1s ease;
}
.card:hover {
/* invert everything inside the card */
filter: invert(0.8) hue-rotate(144deg);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.card-body {
display: flex;
flex-direction: column;
@@ -70,7 +82,6 @@ h3.card-title {
/* Footer styles */
.footer {
margin-top: 12px;
text-align: center;
font-size: 0.7em;
}
@@ -85,8 +96,11 @@ h3.footer-title {
font-size: 1.3em;
}
.card-img-top i {
.card-img-top i, .card-img-top svg{
font-size: 100px;
fill: currentColor;
width: 100px;
height: auto;
}
div#navbarNavheader li.nav-item {
@@ -97,7 +111,7 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px;
}
main {
main, footer, header, nav {
position: relative;
box-shadow:
/* Inner shadow */
@@ -108,3 +122,78 @@ main {
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
overflow: visible;
}
header{
padding: 12px;
}
header,
footer {
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: 0;
z-index: 1030;
background-color: var(--bs-light);
}
/* at the end of default.css */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
iframe{
margin-bottom: -10px;
}
.container-fluid {
max-width: 100% !important;
}
:root {
--anim-duration: 3s; /* Basis-Dauer */
}
.container,
.container-fluid {
transition:
max-width var(--anim-duration) ease-in-out,
padding-left var(--anim-duration) ease-in-out,
padding-right var(--anim-duration) ease-in-out;
}
#navbar_logo {
/* start invisible but in the layout (d-none will actually hide it) */
opacity: 0;
transition: opacity var(--anim-duration) ease-in-out;
}
#navbar_logo.visible {
opacity: 1 !important;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */
max-height: 200px;
padding: 1rem;
transition:
max-height var(--anim-duration) ease-in-out,
padding var(--anim-duration) ease-in-out;
}
/* 2. In fullscreen mode, collapse them */
body.fullscreen header,
body.fullscreen footer {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}

View File

@@ -19,12 +19,6 @@
transition: all 0.3s ease-in-out;
}
nav.navbar.menu-header {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
nav.navbar.menu-footer {
border-top-left-radius: 0;
border-top-right-radius: 0;
nav.navbar {
border-radius: 0;
}

View File

@@ -17,7 +17,7 @@ function adjustScrollContainerHeight() {
// Calculate the available height for the scroll area
const availableHeight = window.innerHeight - siblingsHeight;
scrollContainer.style.maxHeight = availableHeight + 'px';
scrollContainer.style.height = availableHeight + 'px';
scrollContainer.style.overflowY = 'auto';
scrollContainer.style.overflowX = 'hidden';

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

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

View File

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

View File

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

View File

@@ -3,7 +3,11 @@
<head>
<title>{{platform.titel}}</title>
<meta charset="utf-8" >
<link rel="icon" type="image/x-icon" href="{{platform.favicon.cache}}">
<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 -->
@@ -16,14 +20,30 @@
<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>
<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">
<img src="{{platform.logo.cache}}" alt="logo"/>
<header class="header js-restore">
<img
src="{{ url_for('static', filename=platform.logo.cache) }}"
alt="logo"
/>
<h1>{{platform.titel}}</h1>
<h2>{{platform.subtitel}}</h2>
<br />
</header>
{% set menu_type = "header" %}
{% include "moduls/navigation.html.j2"%}
@@ -49,10 +69,16 @@
</div>
<!-- Include modal -->
{% include "moduls/modal.html.j2" %}
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script>
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
<script src="{{ url_for('static', filename='js/custom_scrollbar.js') }}"></script>
<script src="{{ url_for('static', filename='js/iframe.js') }}"></script>
{% for name in [
'modal',
'navigation',
'tooltip',
'container',
'fullwidth',
'fullscreen',
'iframe',
] %}
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
{% endfor %}
</body>
</html>

View File

@@ -2,17 +2,28 @@
<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>
{% else %}
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
{% 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 %}">
<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 %}

View File

@@ -8,26 +8,39 @@
{% endmacro %}
<!-- Template for children -->
{% macro render_children(children) %}
{% for children in children %}
{% if children.children %}
{% for child in children %}
{% if child.children %}
<li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
<ul class="dropdown-menu">
{{ render_children(children.children) }}
{{ render_children(child.children) }}
</ul>
</li>
{% elif children.identifier or children.warning or children.info %}
{% elif child.identifier or child.warning or child.info %}
<li>
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
<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 children.iframe %}iframe-link{% endif %}" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
<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 %}
@@ -35,18 +48,37 @@
{% endmacro %}
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}}">
<div class="container-fluid">
<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 %}
<!-- Single Item -->
{% if item.url or item.onclick %}
<li class="nav-item">
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
<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>
@@ -68,5 +100,4 @@
{% endfor %}
</ul>
</div>
</div>
</nav>

View File

@@ -1,66 +1,45 @@
import os
import hashlib
import requests
import mimetypes
class CacheManager:
"""
A class to manage caching of files, including creating temporary directories
and caching files locally with hashed filenames.
"""
def __init__(self, cache_dir="static/cache"):
"""
Initialize the CacheManager with a cache directory.
:param cache_dir: The directory where cached files will be stored.
"""
self.cache_dir = cache_dir
self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self):
"""
Ensure the cache directory exists. If it doesn't, create it.
"""
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
def clear_cache(self):
"""
Clear all files in the cache directory.
"""
if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir):
file_path = os.path.join(self.cache_dir, filename)
if os.path.isfile(file_path):
os.remove(file_path)
path = os.path.join(self.cache_dir, filename)
if os.path.isfile(path):
os.remove(path)
def cache_file(self, file_url):
"""
Download a file and store it locally in the cache directory with a hashed filename.
# 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]
:param file_url: The URL of the file to cache.
:return: The local path of the cached file.
"""
# Generate a hashed filename based on the URL
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
hash_suffix = hash_object.hexdigest()
try:
resp = requests.get(file_url, stream=True, timeout=5)
resp.raise_for_status()
except requests.RequestException:
return None
# Determine the base name for the file
splitted_file_url = file_url.split("/")
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1]
# Construct the full path for the cached file
filename = f"{base_name}_{hash_suffix}.png"
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 the file already exists, return the cached path
if os.path.exists(full_path):
return full_path
if not os.path.exists(full_path):
with open(full_path, "wb") as f:
for chunk in resp.iter_content(1024):
f.write(chunk)
# Download the file and save it locally
response = requests.get(file_url, stream=True)
if response.status_code == 200:
with open(full_path, "wb") as file:
for chunk in response.iter_content(1024):
file.write(chunk)
return full_path
# return path relative to /static/
return f"cache/{filename}"

View File

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

2
env.example Normal file
View File

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

298
main.py
View File

@@ -1,30 +1,42 @@
#!/usr/bin/env python3
"""
main.py - A CLI tool for managing the Portfolio CMS Docker application.
This script provides commands to build and run the Docker container for the
portfolio application. It mimics the functionality of a Makefile with additional
explanatory text using argparse.
Commands:
build - Build the Docker image.
up - Start the application using docker-compose (with build).
down - Stop and remove the running container.
run-dev - Run the container in development mode (with hot-reloading).
run-prod - Run the container in production mode.
logs - Display the logs of the running container.
dev - Start the application in development mode using docker-compose.
prod - Start the application in production mode using docker-compose.
cleanup - Remove all stopped containers.
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 function to run a shell command."""
"""Utility to run shell commands."""
print(f"Executing: {' '.join(command)}")
if dry_run:
print("Dry run enabled: command not executed.")
@@ -35,262 +47,38 @@ def run_command(command, dry_run=False):
print(f"Error: Command failed with exit code {e.returncode}")
sys.exit(e.returncode)
def build(args):
"""
Build the Docker image for the portfolio application.
Command:
docker build -t application-portfolio .
This command creates a Docker image named 'application-portfolio'
from the Dockerfile in the current directory.
"""
command = ["docker", "build", "-t", "application-portfolio", "."]
run_command(command, args.dry_run)
def up(args):
"""
Start the application using docker-compose with build.
Command:
docker-compose up --build
This command uses docker-compose to build (if necessary) and start
all defined services. It is useful for quickly starting your
development or production environment.
"""
command = ["docker-compose", "up", "--build"]
run_command(command, args.dry_run)
def down(args):
"""
Stop and remove the Docker container named 'portfolio'.
Commands:
docker stop portfolio
docker rm portfolio
These commands stop the running container and remove it from your Docker host.
The '-' prefix is used to ignore errors if the container is not running.
"""
command_stop = ["docker", "stop", "portfolio"]
command_rm = ["docker", "rm", "portfolio"]
run_command(command_stop, args.dry_run)
run_command(command_rm, args.dry_run)
def run_dev(args):
"""
Run the container in development mode with hot-reloading.
Command:
docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app \
-e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
This command starts the container in detached mode (-d), maps port 5000,
mounts the local 'app/' directory into the container, and sets environment
variables to enable Flask's development mode.
"""
current_dir = os.getcwd()
volume_mapping = f"{current_dir}/app/:/app"
command = [
"docker", "run", "-d",
"-p", "5000:5000",
"--name", "portfolio",
"-v", volume_mapping,
"-e", "FLASK_APP=app.py",
"-e", "FLASK_ENV=development",
"application-portfolio"
]
run_command(command, args.dry_run)
def run_prod(args):
"""
Run the container in production mode.
Command:
docker run -d -p 5000:5000 --name portfolio application-portfolio
This command starts the container in detached mode, mapping port 5000,
and runs the production version of the portfolio application.
"""
command = [
"docker", "run", "-d",
"-p", "5000:5000",
"--name", "portfolio",
"application-portfolio"
]
run_command(command, args.dry_run)
def logs(args):
"""
Display the logs of the 'portfolio' container.
Command:
docker logs -f portfolio
This command follows the logs (using -f) of the running container,
which is helpful for debugging and monitoring.
"""
command = ["docker", "logs", "-f", "portfolio"]
run_command(command, args.dry_run)
def dev(args):
"""
Run the application in development mode using docker-compose.
Command:
FLASK_ENV=development docker-compose up -d
This command sets the FLASK_ENV environment variable to 'development'
and starts the application using docker-compose, enabling hot-reloading.
"""
env = os.environ.copy()
env["FLASK_ENV"] = "development"
command = ["docker-compose", "up", "-d"]
print("Setting FLASK_ENV=development")
run_command(command, args.dry_run)
def prod(args):
"""
Run the application in production mode using docker-compose.
Command:
docker-compose up --build
This command builds the Docker image if needed and starts the application
using docker-compose for a production environment.
"""
command = ["docker-compose", "up", "--build"]
run_command(command, args.dry_run)
def cleanup(args):
"""
Remove all stopped Docker containers.
Command:
docker container prune -f
This command cleans up your Docker environment by forcefully removing
all stopped containers. It is useful to reclaim disk space and remove
unused containers.
"""
command = ["docker", "container", "prune", "-f"]
run_command(command, args.dry_run)
def delete_portfolio_container(dry_run=False):
"""
Force remove the portfolio container if it exists.
"""
print("Checking if 'portfolio' container exists to delete...")
command = ["docker", "rm", "-f", "portfolio"]
run_command(command, dry_run)
def browse(args):
"""
Open http://localhost:5000 in Chromium browser.
Command:
chromium http://localhost:5000
This command launches the Chromium browser to view the running application.
"""
command = ["chromium", "http://localhost:5000"]
run_command(command, args.dry_run)
def main():
parser = argparse.ArgumentParser(
description="CLI tool to manage the Portfolio CMS Docker application."
description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the commands without executing them."
)
parser.add_argument(
"--delete",
action="store_true",
help="Delete the existing 'portfolio' container before running the command."
help="Print the generated Make command without executing it."
)
subparsers = parser.add_subparsers(
title="Commands",
description="Available commands to manage the application",
dest="command"
title="Available commands",
dest="command",
required=True
)
# Browse command
parser_browse = subparsers.add_parser(
"browse", help="Open http://localhost:5000 in Chromium browser."
)
parser_browse.set_defaults(func=browse)
# Build command
parser_build = subparsers.add_parser(
"build", help="Build the Docker image."
)
parser_build.set_defaults(func=build)
# Up command (docker-compose up)
parser_up = subparsers.add_parser(
"up", help="Start the application using docker-compose (with build)."
)
parser_up.set_defaults(func=up)
# Down command
parser_down = subparsers.add_parser(
"down", help="Stop and remove the Docker container."
)
parser_down.set_defaults(func=down)
# Run-dev command
parser_run_dev = subparsers.add_parser(
"run-dev", help="Run the container in development mode (with hot-reloading)."
)
parser_run_dev.set_defaults(func=run_dev)
# Run-prod command
parser_run_prod = subparsers.add_parser(
"run-prod", help="Run the container in production mode."
)
parser_run_prod.set_defaults(func=run_prod)
# Logs command
parser_logs = subparsers.add_parser(
"logs", help="Display the logs of the running container."
)
parser_logs.set_defaults(func=logs)
# Dev command (docker-compose with FLASK_ENV)
parser_dev = subparsers.add_parser(
"dev", help="Start the application in development mode using docker-compose."
)
parser_dev.set_defaults(func=dev)
# Prod command (docker-compose production)
parser_prod = subparsers.add_parser(
"prod", help="Start the application in production mode using docker-compose."
)
parser_prod.set_defaults(func=prod)
# Cleanup command
parser_cleanup = subparsers.add_parser(
"cleanup", help="Remove all stopped Docker containers."
)
parser_cleanup.set_defaults(func=cleanup)
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 args.command is None:
if not args.command:
parser.print_help()
sys.exit(1)
if args.delete:
delete_portfolio_container(args.dry_run)
cmd = ["make", args.target]
run_command(cmd, dry_run=args.dry_run)
# Execute the chosen subcommand function
args.func(args)
if __name__ == "__main__":
from pathlib import Path
main()

1
requirements.txt Normal file
View File

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