Compare commits

...

13 Commits

22 changed files with 1036 additions and 419 deletions

1
.gitignore vendored
View File

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

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

@@ -33,15 +33,29 @@ def load_config(app):
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"]
# 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"]
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"])
# Initialize Flask app
app = Flask(__name__)

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

@@ -173,6 +173,11 @@ iframe{
transition: opacity var(--anim-duration) ease-in-out;
}
#navbar_logo.visible {
opacity: 1 !important;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {

View File

@@ -41,14 +41,11 @@ function enterFullscreen() {
setFullWidth(true);
updateUrlFullscreen(true);
// fade in logo… (unchanged)
// Nur jetzt sichtbar machen
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.classList.remove('d-none');
requestAnimationFrame(() => logo.style.opacity = '1');
logo.classList.add('visible');
}
// now recalc in lock-step with the CSS collapse animation
recalcWhileCollapsing();
}
@@ -57,19 +54,11 @@ function exitFullscreen() {
setFullWidth(false);
updateUrlFullscreen(false);
// fade out logo… (unchanged)
// Jetzt wieder verstecken
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.style.opacity = '0';
logo.addEventListener('transitionend', function handler(e) {
if (e.propertyName === 'opacity') {
logo.classList.add('d-none');
logo.removeEventListener('transitionend', handler);
logo.classList.remove('visible');
}
});
}
// recalc while header/footer expand back
recalcWhileCollapsing();
}

View File

@@ -1,5 +1,16 @@
// 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() {
@@ -23,8 +34,6 @@ function syncIframeHeight() {
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
function openIframe(url) {
enterFullscreen();
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
var $customScroll = customScrollbar ? $(customScrollbar) : null;
var $main = $(mainElement);
@@ -35,7 +44,9 @@ function openIframe(url) {
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
$.when.apply($, promises).done(function() {
// Iframe anlegen, falls noch nicht vorhanden
// 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;
@@ -62,37 +73,32 @@ function openIframe(url) {
});
}
// Function to restore the original content (jQuery version mit 1500 ms Fade)
/**
* Restore the original <main> content and exit fullscreen.
*/
function restoreOriginal() {
var $main = $(mainElement);
var $iframe = $main.find('iframe');
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
var $customScroll = customScrollbar ? $(customScrollbar) : null;
// Exit fullscreen (collapse header/footer and run recalcs)
exitFullscreen();
if ($iframe.length) {
// Iframe mit 1500 ms ausblenden, dann entfernen und Original einblenden
$iframe.fadeOut(1500, function() {
$iframe.remove();
// Replace <main> innerHTML with the snapshot we took on load
mainElement.innerHTML = originalContent;
if ($container) $container.fadeIn(1500);
if ($customScroll) $customScroll.fadeIn(1500);
// Inline-Style des main-Elements zurücksetzen
// Reset any inline styles on mainElement
if (originalMainStyle !== null) {
$main.attr('style', originalMainStyle);
mainElement.setAttribute('style', originalMainStyle);
} else {
$main.removeAttr('style');
mainElement.removeAttribute('style');
}
// URL-Parameter entfernen
var newUrl = new URL(window.location);
newUrl.searchParams.delete('iframe');
window.history.pushState({}, '', newUrl);
});
}
// 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
@@ -103,41 +109,25 @@ document.addEventListener("DOMContentLoaded", function() {
customScrollbar = document.getElementById("custom-scrollbar");
scrollbarContainer = container.querySelector(".scroll-container")
// Attach click handlers to links that should open in an iframe
document.querySelectorAll(".iframe-link").forEach(link => {
link.addEventListener("click", function(event) {
event.preventDefault(); // prevent full page navigation
openIframe(this.href);
updateUrlFullWidth(true);
});
});
document.querySelectorAll(".js-restore").forEach(el => {
el.style.cursor = "pointer";
el.addEventListener("click", restoreOriginal);
});
// On full page load, check URL parameters to auto-open an iframe
window.addEventListener("load", function() {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl) {
openIframe(iframeUrl);
}
});
});
// Handle browser back/forward navigation
window.addEventListener('popstate', function(event) {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl) {
openIframe(iframeUrl);
} else {
// === 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);
});
});
});
/**
@@ -181,3 +171,19 @@ function observeIframeNavigation() {
}
}, 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

@@ -54,7 +54,7 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
{% if menu_type == "header" %}
<a class="navbar-brand d-flex align-items-center d-none js-restore" id="navbar_logo" href="#">
<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 }}"

View File

@@ -5,14 +5,11 @@ services:
build:
context: .
dockerfile: Dockerfile
image: application-portfolio
container_name: portfolio
ports:
- "${PORT:-5000}:${PORT:-5000}"
env_file:
- .env
volumes:
- ./app:/app
- ./.env:/app./.env
environment:
- PORT=${PORT:-5000}
- FLASK_ENV=${FLASK_ENV:-production}
restart: unless-stopped

View File

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

300
main.py
View File

@@ -1,300 +1,84 @@
#!/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
from dotenv import load_dotenv
import re
from pathlib import Path
dotenv_path = Path(__file__).resolve().parent / ".env"
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
if dotenv_path.exists():
load_dotenv(dotenv_path)
else:
print(f"⚠️ Warning: No .env file found at {dotenv_path}")
PORT = int(os.getenv("PORT", 5000))
def run_command(command, dry_run=False, env=None):
"""Utility function to run a shell command."""
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, env=env)
subprocess.check_call(command)
except subprocess.CalledProcessError as e:
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", f"{PORT}:{PORT}",
"--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", "{PORT}: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.
"""
env = os.environ.copy()
env["FLASK_ENV"] = "development"
command = ["docker-compose", "up", "-d"]
print("▶️ Starting in development mode (FLASK_ENV=development)")
run_command(command, args.dry_run, env=env)
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", f"http://localhost:{PORT}"]
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 application 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()