mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2025-09-09 11:17:12 +02:00
Compare commits
24 Commits
531c2295bd
...
main
Author | SHA1 | Date | |
---|---|---|---|
f8c2b4236b | |||
dc2626e020 | |||
46b0b744ca | |||
5f2e7ef696 | |||
152a85bfb8 | |||
fdfe301868 | |||
cbfe1ed8ae | |||
9470162236 | |||
6a57fa1e00 | |||
ab67fc0b29 | |||
e18566d801 | |||
7bc0f32145 | |||
6ed3e60dd0 | |||
ab8ea0dbd6 | |||
b0446dcd29 | |||
55d309b2d7 | |||
d99a8c8452 | |||
3f6a195ecb | |||
430ea4a120 | |||
cc0fc9b77f | |||
9ada9acb3a | |||
246ef1b059 | |||
6572a39d48 | |||
a80262c0d4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ app/config.yaml
|
||||
*__pycache__*
|
||||
app/static/cache/*
|
||||
.env
|
||||
app/cypress/screenshots/*
|
82
Makefile
Normal file
82
Makefile
Normal file
@@ -0,0 +1,82 @@
|
||||
# Load environment variables from .env
|
||||
ifneq (,$(wildcard .env))
|
||||
include .env
|
||||
# Export variables defined in .env
|
||||
export $(shell sed 's/=.*//' .env)
|
||||
endif
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
PORT ?= 5000
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
.PHONY: build
|
||||
build:
|
||||
# Build the Docker image.
|
||||
docker build -t application-portfolio .
|
||||
|
||||
.PHONY: up
|
||||
up:
|
||||
# Start the application using docker-compose with build.
|
||||
docker-compose up -d --build
|
||||
|
||||
.PHONY: down
|
||||
down:
|
||||
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
|
||||
- docker stop portfolio || true
|
||||
- docker rm portfolio || true
|
||||
- docker-compose down
|
||||
|
||||
.PHONY: run-dev
|
||||
run-dev:
|
||||
# Run the container in development mode (hot-reload).
|
||||
docker run -d \
|
||||
-p $(PORT):$(PORT) \
|
||||
--name portfolio \
|
||||
-v $(PWD)/app/:/app \
|
||||
-e FLASK_APP=app.py \
|
||||
-e FLASK_ENV=development \
|
||||
application-portfolio
|
||||
|
||||
.PHONY: run-prod
|
||||
run-prod:
|
||||
# Run the container in production mode.
|
||||
docker run -d \
|
||||
-p $(PORT):$(PORT) \
|
||||
--name portfolio \
|
||||
application-portfolio
|
||||
|
||||
.PHONY: logs
|
||||
logs:
|
||||
# Display the logs of the 'portfolio' container.
|
||||
docker logs -f portfolio
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
# Start the application in development mode using docker-compose.
|
||||
FLASK_ENV=development docker-compose up -d
|
||||
|
||||
.PHONY: prod
|
||||
prod:
|
||||
# Start the application in production mode using docker-compose (with build).
|
||||
docker-compose up -d --build
|
||||
|
||||
.PHONY: cleanup
|
||||
cleanup:
|
||||
# Remove all stopped Docker containers to reclaim space.
|
||||
docker container prune -f
|
||||
|
||||
.PHONY: delete
|
||||
delete:
|
||||
# Force remove the 'portfolio' container if it exists.
|
||||
- docker rm -f portfolio
|
||||
|
||||
.PHONY: browse
|
||||
browse:
|
||||
# Open the application in the browser at http://localhost:$(PORT)
|
||||
chromium http://localhost:$(PORT)
|
||||
|
||||
npm-install:
|
||||
cd app && npm install
|
||||
|
||||
test: npm-install
|
||||
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"
|
147
README.md
147
README.md
@@ -1,86 +1,98 @@
|
||||
# Portfolio CMS: Flask-based Portfolio Management 🚀
|
||||
# PortUI 🖥️✨
|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](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/) (Kevin’s 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 Kevin’s 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 site’s 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
2
app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
38
app/app.py
38
app/app.py
@@ -11,6 +11,8 @@ 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()
|
||||
@@ -31,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__)
|
||||
@@ -48,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."""
|
||||
|
19
app/cypress.config.js
Normal file
19
app/cypress.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// cypress.config.js
|
||||
const { defineConfig } = require('cypress');
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
// your app under test must already be running on this port
|
||||
baseUrl: `http://localhost:${process.env.PORT || 5001}`,
|
||||
defaultCommandTimeout: 60000,
|
||||
pageLoadTimeout: 60000,
|
||||
requestTimeout: 1500,
|
||||
responseTimeout: 15000,
|
||||
specPattern: 'cypress/e2e/**/*.spec.js',
|
||||
supportFile: false,
|
||||
setupNodeEvents(on, config) {
|
||||
// here you could hook into events, but we don’t need anything special
|
||||
return config;
|
||||
}
|
||||
},
|
||||
});
|
90
app/cypress/e2e/container.spec.js
Normal file
90
app/cypress/e2e/container.spec.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// cypress/e2e/container.spec.js
|
||||
|
||||
describe('Custom Scroll & Container Resizing', () => {
|
||||
beforeEach(() => {
|
||||
// Assumes your app is running at baseUrl, and container.js is loaded on “/”
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('on load, the scroll-container gets a positive height and proper overflow', () => {
|
||||
// wait for our JS to run
|
||||
cy.window().should('have.property', 'adjustScrollContainerHeight');
|
||||
|
||||
// Grab the inline style of .scroll-container
|
||||
cy.get('.scroll-container')
|
||||
.should('have.attr', 'style')
|
||||
.then(style => {
|
||||
// height:<number>px must be present
|
||||
const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
|
||||
expect(m, 'height set').to.not.be.null;
|
||||
expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
|
||||
|
||||
// overflow shorthand should include both hidden & auto (order-insensitive)
|
||||
expect(style).to.include('overflow:');
|
||||
expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
|
||||
});
|
||||
});
|
||||
|
||||
it('on window resize, scroll-container height updates', () => {
|
||||
// record original height
|
||||
cy.get('.scroll-container')
|
||||
.invoke('css', 'height')
|
||||
.then(orig => {
|
||||
// resize to a smaller viewport
|
||||
cy.viewport(320, 480);
|
||||
cy.wait(100); // allow resize handler to fire
|
||||
|
||||
cy.get('.scroll-container')
|
||||
.invoke('css', 'height')
|
||||
.then(newH => {
|
||||
expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
context('custom scrollbar thumb', () => {
|
||||
beforeEach(() => {
|
||||
// inject tall content to force scrolling
|
||||
cy.get('.scroll-container').then($sc => {
|
||||
$sc[0].innerHTML = '<div style="height:2000px">long</div>';
|
||||
});
|
||||
// re-run scrollbar setup
|
||||
cy.window().invoke('updateCustomScrollbar');
|
||||
});
|
||||
|
||||
it('shows a thumb with reasonable size & position', () => {
|
||||
cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
|
||||
|
||||
cy.get('#scroll-thumb')
|
||||
.should('have.css', 'height')
|
||||
.then(h => {
|
||||
const hh = parseFloat(h);
|
||||
expect(hh).to.be.at.least(20);
|
||||
// ensure thumb is smaller than container
|
||||
cy.get('#custom-scrollbar')
|
||||
.invoke('css', 'height')
|
||||
.then(ch => {
|
||||
expect(hh).to.be.lessThan(parseFloat(ch));
|
||||
});
|
||||
});
|
||||
|
||||
// scroll a bit and verify thumb.top changes
|
||||
cy.get('.scroll-container').scrollTo(0, 200);
|
||||
cy.wait(50);
|
||||
cy.get('#scroll-thumb')
|
||||
.invoke('css', 'top')
|
||||
.then(t => {
|
||||
expect(parseFloat(t)).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides scrollbar when content fits', () => {
|
||||
// remove overflow
|
||||
cy.get('.scroll-container').then($sc => {
|
||||
$sc[0].innerHTML = '<div style="height:10px">tiny</div>';
|
||||
});
|
||||
cy.window().invoke('updateCustomScrollbar');
|
||||
cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
|
||||
});
|
||||
});
|
||||
});
|
85
app/cypress/e2e/fullscreen.spec.js
Normal file
85
app/cypress/e2e/fullscreen.spec.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// cypress/e2e/fullscreen.spec.js
|
||||
|
||||
describe('Fullscreen Toggle', () => {
|
||||
const ROOT = '/';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit(ROOT);
|
||||
});
|
||||
|
||||
it('defaults to normal mode when no fullscreen param is present', () => {
|
||||
// Body should not have fullscreen class
|
||||
cy.get('body').should('not.have.class', 'fullscreen');
|
||||
|
||||
// URL should not include `fullscreen`
|
||||
cy.url().should('not.include', 'fullscreen=');
|
||||
|
||||
// Header and footer should be visible (max-height > 0)
|
||||
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||
});
|
||||
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
|
||||
cy.visit(`${ROOT}?fullscreen=1`);
|
||||
|
||||
cy.get('body').should('have.class', 'fullscreen');
|
||||
cy.url().should('include', 'fullscreen=1');
|
||||
|
||||
// Header and footer should be collapsed (max-height == 0)
|
||||
cy.get('header').should('have.css', 'max-height', '0px');
|
||||
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||
});
|
||||
|
||||
it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
|
||||
cy.window().then(win => {
|
||||
win.exitFullscreen(); // ensure starting state
|
||||
win.enterFullscreen();
|
||||
});
|
||||
|
||||
cy.get('body').should('have.class', 'fullscreen');
|
||||
cy.url().should('include', 'fullscreen=1');
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container-fluid');
|
||||
|
||||
cy.get('header').should('have.css', 'max-height', '0px');
|
||||
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||
});
|
||||
|
||||
it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
|
||||
// start in fullscreen
|
||||
cy.window().invoke('enterFullscreen');
|
||||
|
||||
// then exit
|
||||
cy.window().invoke('exitFullscreen');
|
||||
|
||||
cy.get('body').should('not.have.class', 'fullscreen');
|
||||
cy.url().should('not.include', 'fullscreen=');
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container')
|
||||
.and('not.have.class', 'container-fluid');
|
||||
|
||||
// Header and footer should be expanded again
|
||||
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||
});
|
||||
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('toggleFullscreen() toggles into and out of fullscreen', () => {
|
||||
// Toggle into fullscreen
|
||||
cy.window().invoke('toggleFullscreen');
|
||||
cy.get('body').should('have.class', 'fullscreen');
|
||||
cy.url().should('include', 'fullscreen=1');
|
||||
|
||||
// Toggle back
|
||||
cy.window().invoke('toggleFullscreen');
|
||||
cy.get('body').should('not.have.class', 'fullscreen');
|
||||
cy.url().should('not.include', 'fullscreen=');
|
||||
});
|
||||
});
|
61
app/cypress/e2e/fullwidth.spec.js
Normal file
61
app/cypress/e2e/fullwidth.spec.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// cypress/e2e/fullwidth.spec.js
|
||||
|
||||
describe('Full-width Toggle', () => {
|
||||
// test page must include your <div class="container"> wrapper
|
||||
const ROOT = '/';
|
||||
|
||||
it('defaults to .container when no param is present', () => {
|
||||
cy.visit(ROOT);
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container')
|
||||
.and('not.have.class', 'container-fluid');
|
||||
|
||||
// URL should not include `fullwidth`
|
||||
cy.url().should('not.include', 'fullwidth=');
|
||||
});
|
||||
|
||||
it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
|
||||
cy.visit(`${ROOT}?fullwidth=1`);
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container-fluid')
|
||||
.and('not.have.class', 'container');
|
||||
cy.url().should('include', 'fullwidth=1');
|
||||
});
|
||||
|
||||
it('setFullWidth(true) switches to container-fluid and updates URL', () => {
|
||||
cy.visit(ROOT);
|
||||
|
||||
// call your global function
|
||||
cy.window().invoke('setFullWidth', true);
|
||||
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container-fluid')
|
||||
.and('not.have.class', 'container');
|
||||
|
||||
cy.url().should('include', 'fullwidth=1');
|
||||
});
|
||||
|
||||
it('setFullWidth(false) reverts to container and removes URL param', () => {
|
||||
cy.visit(`${ROOT}?fullwidth=1`);
|
||||
|
||||
// now reset
|
||||
cy.window().invoke('setFullWidth', false);
|
||||
|
||||
cy.get('.container, .container-fluid')
|
||||
.should('have.class', 'container')
|
||||
.and('not.have.class', 'container-fluid');
|
||||
|
||||
cy.url().should('not.include', 'fullwidth=1');
|
||||
});
|
||||
|
||||
it('updateUrlFullWidth() toggles the query param without changing layout', () => {
|
||||
cy.visit(ROOT);
|
||||
|
||||
// manually toggle URL only
|
||||
cy.window().invoke('updateUrlFullWidth', true);
|
||||
cy.url().should('include', 'fullwidth=1');
|
||||
|
||||
cy.window().invoke('updateUrlFullWidth', false);
|
||||
cy.url().should('not.include', 'fullwidth=');
|
||||
});
|
||||
});
|
46
app/cypress/e2e/iframe.spec.js
Normal file
46
app/cypress/e2e/iframe.spec.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// cypress/e2e/iframe.spec.js
|
||||
|
||||
describe('Iframe integration', () => {
|
||||
beforeEach(() => {
|
||||
// Visit the app’s base URL (configured in cypress.config.js)
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('opens the iframe when an .iframe-link is clicked', () => {
|
||||
// Find the first iframe-link on the page
|
||||
cy.get('.iframe-link').first().then($link => {
|
||||
const href = $link.prop('href');
|
||||
|
||||
// Click it
|
||||
cy.wrap($link).click();
|
||||
|
||||
// The URL should now include ?iframe=<encoded href>
|
||||
cy.url().should('include', 'iframe=' + encodeURIComponent(href));
|
||||
|
||||
// The <body> should have the "fullscreen" class
|
||||
cy.get('body').should('have.class', 'fullscreen');
|
||||
|
||||
// And the <main> should contain a visible <iframe src="<href>">
|
||||
cy.get('main iframe')
|
||||
.should('have.attr', 'src', href)
|
||||
.and('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original content when a .js-restore element is clicked', () => {
|
||||
// First open the iframe
|
||||
cy.get('.iframe-link').first().click();
|
||||
|
||||
// Then click the first .js-restore element (e.g. header or logo)
|
||||
cy.get('.js-restore').first().click();
|
||||
|
||||
// The URL must no longer include the iframe parameter
|
||||
cy.url().should('not.include', 'iframe=');
|
||||
|
||||
// The <body> should no longer have the "fullscreen" class
|
||||
cy.get('body').should('not.have.class', 'fullscreen');
|
||||
|
||||
// And no <iframe> should remain inside <main>
|
||||
cy.get('main iframe').should('not.exist');
|
||||
});
|
||||
});
|
130
app/cypress/e2e/menu.spec.js
Normal file
130
app/cypress/e2e/menu.spec.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// cypress/e2e/dynamic_popup.spec.js
|
||||
|
||||
describe('Dynamic Popup', () => {
|
||||
const base = {
|
||||
name: 'Test Item',
|
||||
identifier: 'ABC123',
|
||||
description: 'A simple description',
|
||||
warning: '**Be careful!**',
|
||||
info: '_Some info_',
|
||||
url: null,
|
||||
iframe: false,
|
||||
icon: { class: 'fa fa-test' },
|
||||
alternatives: [
|
||||
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||
],
|
||||
children: [
|
||||
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.window().then(win => {
|
||||
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||
cy.stub(win, 'alert');
|
||||
});
|
||||
});
|
||||
|
||||
function open(item = {}) {
|
||||
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||
}
|
||||
|
||||
it('renders title with icon and text', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i.fa.fa-test')
|
||||
.should('exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('falls back to plain text when no icon', () => {
|
||||
open({ icon: null });
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i')
|
||||
.should('not.exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('have.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('shows identifier when provided and populates input', () => {
|
||||
open();
|
||||
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||
});
|
||||
|
||||
it('hides identifier box when none', () => {
|
||||
open({ identifier: null });
|
||||
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', '');
|
||||
});
|
||||
|
||||
it('renders warning and info via marked', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalWarning')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalWarningText')
|
||||
.should('contain.html', '<strong>Be careful!</strong>');
|
||||
cy.get('#dynamicModalInfo')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalInfoText')
|
||||
.should('contain.html', '<em>Some info</em>');
|
||||
});
|
||||
|
||||
it('hides warning/info when none provided', () => {
|
||||
open({ warning: null, info: null });
|
||||
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows description when no URL', () => {
|
||||
open({ url: null, description: 'Only desc' });
|
||||
cy.get('#dynamicDescriptionText')
|
||||
.should('not.have.class', 'd-none')
|
||||
.and('have.text', 'Only desc');
|
||||
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows link when URL is provided', () => {
|
||||
open({ url: 'https://example.com', description: 'Click me' });
|
||||
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalLinkHref')
|
||||
.should('have.attr', 'href', 'https://example.com')
|
||||
.and('have.text', 'Click me');
|
||||
});
|
||||
|
||||
it('populates alternatives and children lists', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicAlternativesList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Alt One');
|
||||
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Child One');
|
||||
});
|
||||
|
||||
it('hides sections when no items', () => {
|
||||
open({ alternatives: [], children: [] });
|
||||
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesList button').click();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Alt One');
|
||||
});
|
||||
|
||||
it('copy button selects & copies identifier', () => {
|
||||
open();
|
||||
cy.get('#dynamicCopyButton').click();
|
||||
cy.window().its('navigator.clipboard.writeText')
|
||||
.should('have.been.calledWith', 'ABC123');
|
||||
cy.window().its('alert')
|
||||
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||
});
|
||||
});
|
130
app/cypress/e2e/modal.spec.js
Normal file
130
app/cypress/e2e/modal.spec.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// cypress/e2e/dynamic_popup.spec.js
|
||||
|
||||
describe('Dynamic Popup', () => {
|
||||
const base = {
|
||||
name: 'Test Item',
|
||||
identifier: 'ABC123',
|
||||
description: 'A simple description',
|
||||
warning: '**Be careful!**',
|
||||
info: '_Some info_',
|
||||
url: null,
|
||||
iframe: false,
|
||||
icon: { class: 'fa fa-test' },
|
||||
alternatives: [
|
||||
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||
],
|
||||
children: [
|
||||
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.window().then(win => {
|
||||
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||
cy.stub(win, 'alert');
|
||||
});
|
||||
});
|
||||
|
||||
function open(item = {}) {
|
||||
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||
}
|
||||
|
||||
it('renders title with icon and text', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i.fa.fa-test')
|
||||
.should('exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('falls back to plain text when no icon', () => {
|
||||
open({ icon: null });
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i')
|
||||
.should('not.exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('have.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('shows identifier when provided and populates input', () => {
|
||||
open();
|
||||
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||
});
|
||||
|
||||
it('hides identifier box when none', () => {
|
||||
open({ identifier: null });
|
||||
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', '');
|
||||
});
|
||||
|
||||
it('renders warning and info via marked', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalWarning')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalWarningText')
|
||||
.should('contain.html', '<strong>Be careful!</strong>');
|
||||
cy.get('#dynamicModalInfo')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalInfoText')
|
||||
.should('contain.html', '<em>Some info</em>');
|
||||
});
|
||||
|
||||
it('hides warning/info when none provided', () => {
|
||||
open({ warning: null, info: null });
|
||||
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows description when no URL', () => {
|
||||
open({ url: null, description: 'Only desc' });
|
||||
cy.get('#dynamicDescriptionText')
|
||||
.should('not.have.class', 'd-none')
|
||||
.and('have.text', 'Only desc');
|
||||
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows link when URL is provided', () => {
|
||||
open({ url: 'https://example.com', description: 'Click me' });
|
||||
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalLinkHref')
|
||||
.should('have.attr', 'href', 'https://example.com')
|
||||
.and('have.text', 'Click me');
|
||||
});
|
||||
|
||||
it('populates alternatives and children lists', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicAlternativesList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Alt One');
|
||||
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Child One');
|
||||
});
|
||||
|
||||
it('hides sections when no items', () => {
|
||||
open({ alternatives: [], children: [] });
|
||||
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesList button').click();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Alt One');
|
||||
});
|
||||
|
||||
it('copy button selects & copies identifier', () => {
|
||||
open();
|
||||
cy.get('#dynamicCopyButton').click();
|
||||
cy.window().its('navigator.clipboard.writeText')
|
||||
.should('have.been.calledWith', 'ABC123');
|
||||
cy.window().its('alert')
|
||||
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||
});
|
||||
});
|
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
@@ -0,0 +1,32 @@
|
||||
describe('Navbar Logo Visibility', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('should have #navbar_logo present in the DOM', () => {
|
||||
cy.get('#navbar_logo').should('exist');
|
||||
});
|
||||
|
||||
it('should be invisible (opacity 0) by default', () => {
|
||||
cy.get('#navbar_logo')
|
||||
.should('exist')
|
||||
.and('have.css', 'opacity', '0');
|
||||
});
|
||||
|
||||
it('should become visible (opacity 1) after entering fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
.should('have.css', 'opacity', '1');
|
||||
});
|
||||
|
||||
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
win.exitFullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
.should('have.css', 'opacity', '0');
|
||||
});
|
||||
});
|
130
app/cypress/e2e/tooltips.spec.js
Normal file
130
app/cypress/e2e/tooltips.spec.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// cypress/e2e/dynamic_popup.spec.js
|
||||
|
||||
describe('Dynamic Popup', () => {
|
||||
const base = {
|
||||
name: 'Test Item',
|
||||
identifier: 'ABC123',
|
||||
description: 'A simple description',
|
||||
warning: '**Be careful!**',
|
||||
info: '_Some info_',
|
||||
url: null,
|
||||
iframe: false,
|
||||
icon: { class: 'fa fa-test' },
|
||||
alternatives: [
|
||||
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||
],
|
||||
children: [
|
||||
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.window().then(win => {
|
||||
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||
cy.stub(win, 'alert');
|
||||
});
|
||||
});
|
||||
|
||||
function open(item = {}) {
|
||||
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||
}
|
||||
|
||||
it('renders title with icon and text', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i.fa.fa-test')
|
||||
.should('exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('falls back to plain text when no icon', () => {
|
||||
open({ icon: null });
|
||||
cy.get('#dynamicModalLabel')
|
||||
.find('i')
|
||||
.should('not.exist');
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('have.text', 'Test Item');
|
||||
});
|
||||
|
||||
it('shows identifier when provided and populates input', () => {
|
||||
open();
|
||||
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||
});
|
||||
|
||||
it('hides identifier box when none', () => {
|
||||
open({ identifier: null });
|
||||
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalContent').should('have.value', '');
|
||||
});
|
||||
|
||||
it('renders warning and info via marked', () => {
|
||||
open();
|
||||
cy.get('#dynamicModalWarning')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalWarningText')
|
||||
.should('contain.html', '<strong>Be careful!</strong>');
|
||||
cy.get('#dynamicModalInfo')
|
||||
.should('not.have.class', 'd-none')
|
||||
.find('#dynamicModalInfoText')
|
||||
.should('contain.html', '<em>Some info</em>');
|
||||
});
|
||||
|
||||
it('hides warning/info when none provided', () => {
|
||||
open({ warning: null, info: null });
|
||||
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows description when no URL', () => {
|
||||
open({ url: null, description: 'Only desc' });
|
||||
cy.get('#dynamicDescriptionText')
|
||||
.should('not.have.class', 'd-none')
|
||||
.and('have.text', 'Only desc');
|
||||
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('shows link when URL is provided', () => {
|
||||
open({ url: 'https://example.com', description: 'Click me' });
|
||||
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicModalLinkHref')
|
||||
.should('have.attr', 'href', 'https://example.com')
|
||||
.and('have.text', 'Click me');
|
||||
});
|
||||
|
||||
it('populates alternatives and children lists', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicAlternativesList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Alt One');
|
||||
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenList li')
|
||||
.should('have.length', 1)
|
||||
.first().contains('Child One');
|
||||
});
|
||||
|
||||
it('hides sections when no items', () => {
|
||||
open({ alternatives: [], children: [] });
|
||||
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||
});
|
||||
|
||||
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||
open();
|
||||
cy.get('#dynamicAlternativesList button').click();
|
||||
cy.get('#dynamicModalLabel')
|
||||
.should('contain.text', 'Alt One');
|
||||
});
|
||||
|
||||
it('copy button selects & copies identifier', () => {
|
||||
open();
|
||||
cy.get('#dynamicCopyButton').click();
|
||||
cy.window().its('navigator.clipboard.writeText')
|
||||
.should('have.been.calledWith', 'ABC123');
|
||||
cy.window().its('alert')
|
||||
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||
});
|
||||
});
|
5
app/package.json
Normal file
5
app/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"cypress": "^14.5.1"
|
||||
}
|
||||
}
|
@@ -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;
|
||||
@@ -84,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 {
|
||||
@@ -158,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 {
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
|
@@ -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() {
|
||||
@@ -21,69 +32,71 @@ function syncIframeHeight() {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to open a URL in an iframe
|
||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||
function openIframe(url) {
|
||||
enterFullscreen()
|
||||
// Hide the container (and its scroll-container) so the iframe can appear in its place
|
||||
if (scrollbarContainer) {
|
||||
scrollbarContainer.style.display = 'none';
|
||||
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
||||
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
||||
var $main = $(mainElement);
|
||||
|
||||
// Original-Content ausblenden mit 1500 ms
|
||||
var promises = [];
|
||||
if ($container) promises.push($container.fadeOut(1500).promise());
|
||||
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
|
||||
|
||||
$.when.apply($, promises).done(function() {
|
||||
// now that scroll areas are hidden, go fullscreen
|
||||
enterFullscreen();
|
||||
// create iframe if it doesn’t exist yet
|
||||
var $iframe = $main.find('iframe');
|
||||
if ($iframe.length === 0) {
|
||||
originalMainStyle = $main.attr('style') || null;
|
||||
$iframe = $('<iframe>', {
|
||||
width: '100%',
|
||||
frameborder: 0,
|
||||
scrolling: 'auto'
|
||||
}).css({ overflow: 'auto', display: 'none' });
|
||||
$main.append($iframe);
|
||||
}
|
||||
|
||||
// Hide any custom scrollbar element if present
|
||||
if (customScrollbar) {
|
||||
customScrollbar.style.display = 'none';
|
||||
}
|
||||
|
||||
// Create or retrieve the iframe in the main element
|
||||
let iframe = mainElement.querySelector("iframe");
|
||||
if (!iframe) {
|
||||
iframe = document.createElement("iframe");
|
||||
iframe.width = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.overflow = "auto"; // Enable internal scrolling
|
||||
iframe.scrolling = "auto";
|
||||
mainElement.appendChild(iframe);
|
||||
// Quelle setzen und mit 1500 ms einblenden
|
||||
$iframe
|
||||
.attr('src', url)
|
||||
.fadeIn(1500, function() {
|
||||
syncIframeHeight();
|
||||
}
|
||||
observeIframeNavigation();
|
||||
});
|
||||
|
||||
// Set the iframe's source URL
|
||||
iframe.src = url;
|
||||
|
||||
// Push the new URL state without reloading the page
|
||||
const newUrl = new URL(window.location);
|
||||
// URL-State pushen
|
||||
var newUrl = new URL(window.location);
|
||||
newUrl.searchParams.set('iframe', url);
|
||||
window.history.pushState({ iframe: url }, '', newUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to restore the original content and show the container again
|
||||
/**
|
||||
* Restore the original <main> content and exit fullscreen.
|
||||
*/
|
||||
function restoreOriginal() {
|
||||
// Remove the iframe from the DOM
|
||||
const iframe = mainElement.querySelector("iframe");
|
||||
if (iframe) {
|
||||
iframe.remove();
|
||||
}
|
||||
// Exit fullscreen (collapse header/footer and run recalcs)
|
||||
exitFullscreen();
|
||||
|
||||
// Show the original container
|
||||
if (scrollbarContainer) {
|
||||
scrollbarContainer.style.display = '';
|
||||
}
|
||||
// Replace <main> innerHTML with the snapshot we took on load
|
||||
mainElement.innerHTML = originalContent;
|
||||
|
||||
// Restore any custom scrollbar
|
||||
if (customScrollbar) {
|
||||
customScrollbar.style.display = '';
|
||||
}
|
||||
|
||||
// Restore the original inline style of the main element
|
||||
// Reset any inline styles on mainElement
|
||||
if (originalMainStyle !== null) {
|
||||
mainElement.setAttribute("style", originalMainStyle);
|
||||
mainElement.setAttribute('style', originalMainStyle);
|
||||
} else {
|
||||
mainElement.removeAttribute("style");
|
||||
mainElement.removeAttribute('style');
|
||||
}
|
||||
|
||||
// Update the URL to remove the iframe parameter
|
||||
const 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
|
||||
@@ -96,42 +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);
|
||||
});
|
||||
|
||||
// Clicking the header's H1 will restore the original view
|
||||
const headerH1 = document.querySelector("header h1");
|
||||
if (headerH1) {
|
||||
headerH1.style.cursor = "pointer";
|
||||
headerH1.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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -151,3 +147,43 @@ 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);
|
||||
});
|
||||
});
|
||||
|
@@ -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,6 +20,11 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
|
||||
<!-- JQuery -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
{% if apod_bg %}
|
||||
@@ -28,8 +37,11 @@
|
||||
{% 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>
|
||||
</header>
|
||||
|
@@ -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 %}
|
||||
|
@@ -54,9 +54,9 @@
|
||||
</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" id="navbar_logo" href="/">
|
||||
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||
<img
|
||||
src="{{ platform.logo.cache }}"
|
||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||
alt="{{ platform.titel }}"
|
||||
class="d-inline-block align-text-top"
|
||||
style="height:2rem">
|
||||
|
@@ -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}"
|
||||
|
@@ -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
|
||||
|
@@ -1,2 +1,2 @@
|
||||
PORT=5000
|
||||
PORT=5001
|
||||
FLASK_ENV=production
|
300
main.py
300
main.py
@@ -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()
|
Reference in New Issue
Block a user