Build system with zip download

This commit is contained in:
Djeex
2025-08-19 19:29:06 +02:00
parent 4ac176f8a9
commit e9a3a5a189
7 changed files with 320 additions and 102 deletions

View File

@ -1,7 +1,14 @@
# --- Imports ---
import logging import logging
import yaml import yaml
import subprocess
import zipfile
import os
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory, render_template from flask import (
Flask, jsonify, request, send_from_directory, render_template,
send_file, after_this_request
)
from src.py.builder.gallery_builder import ( from src.py.builder.gallery_builder import (
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
) )
@ -19,61 +26,64 @@ app = Flask(
static_url_path="" static_url_path=""
) )
# --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
# --- Photos directory (configurable) ---
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
app.config["PHOTOS_DIR"] = PHOTOS_DIR app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint --- # --- Register upload blueprint ---
app.register_blueprint(upload_bp) app.register_blueprint(upload_bp)
# --- Helper functions for theme editor --- # --- Theme editor helper functions ---
def get_theme_name(): def get_theme_name():
"""Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml" site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f: with open(site_yaml_path, "r") as f:
site_yaml = yaml.safe_load(f) site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern") return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name): def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f: with open(theme_yaml_path, "r") as f:
return yaml.safe_load(f) return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml): def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "w") as f: with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name): def get_local_fonts(theme_name):
"""List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
if not fonts_dir.exists(): if not fonts_dir.exists():
return [] return []
# Return full filenames, not just stem
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]] return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- Routes --- # --- ROUTES ---
# --- Main page ---
@app.route("/") @app.route("/")
def index(): def index():
"""Serve the main HTML page."""
return render_template("index.html") return render_template("index.html")
# --- Gallery & Hero API ---
@app.route("/api/gallery", methods=["GET"]) @app.route("/api/gallery", methods=["GET"])
def get_gallery(): def get_gallery():
"""Return JSON list of gallery images from YAML.""" """Get gallery images."""
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", [])) return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"]) @app.route("/api/hero", methods=["GET"])
def get_hero(): def get_hero():
"""Return JSON list of hero images from YAML.""" """Get hero images."""
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
return jsonify(data.get("hero", {}).get("images", [])) return jsonify(data.get("hero", {}).get("images", []))
@app.route("/api/gallery/update", methods=["POST"]) @app.route("/api/gallery/update", methods=["POST"])
def update_gallery_api(): def update_gallery_api():
"""Update gallery images in YAML from frontend JSON.""" """Update gallery images."""
images = request.json images = request.json
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = images data["gallery"]["images"] = images
@ -82,7 +92,7 @@ def update_gallery_api():
@app.route("/api/hero/update", methods=["POST"]) @app.route("/api/hero/update", methods=["POST"])
def update_hero_api(): def update_hero_api():
"""Update hero images in YAML from frontend JSON.""" """Update hero images."""
images = request.json images = request.json
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = images data["hero"]["images"] = images
@ -91,49 +101,48 @@ def update_hero_api():
@app.route("/api/gallery/refresh", methods=["POST"]) @app.route("/api/gallery/refresh", methods=["POST"])
def refresh_gallery(): def refresh_gallery():
"""Refresh gallery YAML from photos/gallery folder.""" """Refresh gallery images from disk."""
update_gallery() update_gallery()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"]) @app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero(): def refresh_hero():
"""Refresh hero YAML from photos/hero folder.""" """Refresh hero images from disk."""
update_hero() update_hero()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"]) @app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo(): def delete_gallery_photo():
"""Delete a gallery photo from disk and return status.""" """Delete a gallery photo."""
data = request.json data = request.json
src = data.get("src") src = data.get("src")
file_path = PHOTOS_DIR / "gallery" / src file_path = PHOTOS_DIR / "gallery" / src
if file_path.exists(): if file_path.exists():
file_path.unlink() file_path.unlink()
return {"status": "ok"} return {"status": "ok"}
return {"error": "File not found"}, 404 return {"error": "File not found"}, 404
@app.route("/api/hero/delete", methods=["POST"]) @app.route("/api/hero/delete", methods=["POST"])
def delete_hero_photo(): def delete_hero_photo():
"""Delete a hero photo from disk and return status.""" """Delete a hero photo."""
data = request.json data = request.json
src = data.get("src") src = data.get("src")
file_path = PHOTOS_DIR / "hero" / src file_path = PHOTOS_DIR / "hero" / src
if file_path.exists(): if file_path.exists():
file_path.unlink() file_path.unlink()
return {"status": "ok"} return {"status": "ok"}
return {"error": "File not found"}, 404 return {"error": "File not found"}, 404
@app.route("/api/gallery/delete_all", methods=["POST"]) @app.route("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos(): def delete_all_gallery_photos():
"""Delete all gallery photos from disk and YAML.""" """Delete all gallery photos."""
gallery_dir = PHOTOS_DIR / "gallery" gallery_dir = PHOTOS_DIR / "gallery"
deleted = 0 deleted = 0
# Remove all files in gallery folder
for file in gallery_dir.glob("*"): for file in gallery_dir.glob("*"):
if file.is_file(): if file.is_file():
file.unlink() file.unlink()
deleted += 1 deleted += 1
# Clear YAML gallery images
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = [] data["gallery"]["images"] = []
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
@ -141,68 +150,69 @@ def delete_all_gallery_photos():
@app.route("/api/hero/delete_all", methods=["POST"]) @app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos(): def delete_all_hero_photos():
"""Delete all hero photos from disk and YAML.""" """Delete all hero photos."""
hero_dir = PHOTOS_DIR / "hero" hero_dir = PHOTOS_DIR / "hero"
deleted = 0 deleted = 0
# Remove all files in hero folder
for file in hero_dir.glob("*"): for file in hero_dir.glob("*"):
if file.is_file(): if file.is_file():
file.unlink() file.unlink()
deleted += 1 deleted += 1
# Clear YAML hero images
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = [] data["hero"]["images"] = []
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted}) return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos ---
@app.route("/photos/<section>/<path:filename>") @app.route("/photos/<section>/<path:filename>")
def photos(section, filename): def photos(section, filename):
"""Serve uploaded photos from disk for a specific section.""" """Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename) return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>") @app.route("/photos/<path:filename>")
def serve_photo(filename): def serve_photo(filename):
"""Serve uploaded photos from disk (generic).""" """Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos" photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename) return send_from_directory(photos_dir, filename)
# --- Site info page & API ---
@app.route("/site-info") @app.route("/site-info")
def site_info(): def site_info():
"""Serve the site info editor page.""" """Render site info editor page."""
return render_template("site-info/index.html") return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"]) @app.route("/api/site-info", methods=["GET"])
def get_site_info(): def get_site_info():
"""Return the site info YAML as JSON.""" """Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f: with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
return jsonify(data) return jsonify(data)
@app.route("/api/site-info", methods=["POST"]) @app.route("/api/site-info", methods=["POST"])
def update_site_info(): def update_site_info():
"""Update the site info YAML from frontend JSON.""" """Update site info YAML."""
data = request.json data = request.json
with open(SITE_YAML, "w") as f: with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes") @app.route("/api/themes")
def list_themes(): def list_themes():
"""List available themes (folders in config/themes).""" """List available themes."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()] themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes) return jsonify(themes)
# --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"]) @app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail(): def upload_thumbnail():
"""Upload a thumbnail image and update site.yaml.""" """Upload thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"] PHOTOS_DIR = app.config["PHOTOS_DIR"]
file = request.files.get("file") file = request.files.get("file")
if not file: if not file:
return {"error": "No file provided"}, 400 return {"error": "No file provided"}, 400
filename = "thumbnail.png" filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename) file.save(PHOTOS_DIR / filename)
# Update site.yaml
with open(SITE_YAML, "r") as f: with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename data.setdefault("social", {})["thumbnail"] = filename
@ -212,13 +222,11 @@ def upload_thumbnail():
@app.route("/api/thumbnail/remove", methods=["POST"]) @app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail(): def remove_thumbnail():
"""Remove the thumbnail image and update site.yaml.""" """Remove thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"] PHOTOS_DIR = app.config["PHOTOS_DIR"]
thumbnail_path = PHOTOS_DIR / "thumbnail.png" thumbnail_path = PHOTOS_DIR / "thumbnail.png"
# Remove thumbnail file if exists
if thumbnail_path.exists(): if thumbnail_path.exists():
thumbnail_path.unlink() thumbnail_path.unlink()
# Update site.yaml to remove thumbnail key
with open(SITE_YAML, "r") as f: with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]: if "social" in data and "thumbnail" in data["social"]:
@ -227,14 +235,14 @@ def remove_thumbnail():
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"]) @app.route("/api/theme/upload", methods=["POST"])
def upload_theme(): def upload_theme():
"""Upload a custom theme folder and save it in config/themes.""" """Upload a custom theme folder."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
return jsonify({"error": "No files provided"}), 400 return jsonify({"error": "No files provided"}), 400
# Get folder name from first file's webkitRelativePath
first_path = files[0].filename first_path = files[0].filename
folder_name = first_path.split("/")[0] if "/" in first_path else "custom" folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
theme_folder = themes_dir / folder_name theme_folder = themes_dir / folder_name
@ -246,14 +254,15 @@ def upload_theme():
file.save(dest_path) file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name}) return jsonify({"status": "ok", "theme": folder_name})
# --- Theme Editor API --- # --- Theme editor page & API ---
@app.route("/theme-editor") @app.route("/theme-editor")
def theme_editor(): def theme_editor():
"""Serve the theme editor page.""" """Render theme editor page."""
return render_template("theme-editor/index.html") return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"]) @app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info(): def api_theme_info():
"""Get or update theme.yaml for current theme."""
theme_name = get_theme_name() theme_name = get_theme_name()
if request.method == "GET": if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name) theme_yaml = get_theme_yaml(theme_name)
@ -272,25 +281,25 @@ def api_theme_info():
@app.route("/api/local-fonts") @app.route("/api/local-fonts")
def api_local_fonts(): def api_local_fonts():
"""List local fonts for a theme."""
theme_name = request.args.get("theme") theme_name = request.args.get("theme")
fonts = get_local_fonts(theme_name) fonts = get_local_fonts(theme_name)
return jsonify(fonts) return jsonify(fonts)
# --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"]) @app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon(): def upload_favicon():
"""Upload favicon to theme folder and update theme.yaml.""" """Upload favicon for a theme."""
theme_name = request.form.get("theme") theme_name = request.form.get("theme")
file = request.files.get("file") file = request.files.get("file")
if not file or not theme_name: if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400 return jsonify({"error": "Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower() ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".jpg", ".jpeg", ".ico"]: if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
return jsonify({"error": "Invalid file type"}), 400 return jsonify({"error": "Invalid file type"}), 400
filename = "favicon" + ext filename = "favicon" + ext
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename) file.save(theme_dir / filename)
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml" theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f: with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f) theme_yaml = yaml.safe_load(f)
@ -301,18 +310,16 @@ def upload_favicon():
@app.route("/api/favicon/remove", methods=["POST"]) @app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon(): def remove_favicon():
"""Remove favicon from theme folder and update theme.yaml.""" """Remove favicon for a theme."""
data = request.get_json() data = request.get_json()
theme_name = data.get("theme") theme_name = data.get("theme")
if not theme_name: if not theme_name:
return jsonify({"error": "Missing theme"}), 400 return jsonify({"error": "Missing theme"}), 400
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
# Remove favicon file
for ext in [".png", ".jpg", ".jpeg", ".ico"]: for ext in [".png", ".jpg", ".jpeg", ".ico"]:
favicon_path = theme_dir / f"favicon{ext}" favicon_path = theme_dir / f"favicon{ext}"
if favicon_path.exists(): if favicon_path.exists():
favicon_path.unlink() favicon_path.unlink()
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml" theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f: with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f) theme_yaml = yaml.safe_load(f)
@ -322,21 +329,24 @@ def remove_favicon():
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>") @app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename): def serve_theme_asset(theme, filename):
"""Serve a theme asset file."""
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename) return send_from_directory(theme_dir, filename)
# --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"]) @app.route("/api/font/upload", methods=["POST"])
def upload_font(): def upload_font():
"""Upload a font file to the theme's fonts folder (only .woff/.woff2 allowed).""" """Upload a font file for a theme."""
theme_name = request.form.get("theme") theme_name = request.form.get("theme")
file = request.files.get("file") file = request.files.get("file")
if not file or not theme_name: if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400 return jsonify({"error": "Missing theme or font"}), 400
ext = Path(file.filename).suffix.lower() ext = Path(file.filename).suffix.lower()
if ext not in [".woff", ".woff2"]: if ext not in [".woff", ".woff2"]:
return jsonify({"error": "Invalid font file type"}), 400 return jsonify({"error": "Invalid font file type"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True) fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename) file.save(fonts_dir / file.filename)
@ -344,18 +354,90 @@ def upload_font():
@app.route("/api/font/remove", methods=["POST"]) @app.route("/api/font/remove", methods=["POST"])
def remove_font(): def remove_font():
"""Remove a font file from the theme's fonts folder.""" """Remove a font file for a theme."""
data = request.get_json() data = request.get_json()
theme_name = data.get("theme") theme_name = data.get("theme")
font = data.get("font") font = data.get("font")
if not theme_name or not font: if not theme_name or not font:
return jsonify({"error": "Missing theme or font"}), 400 return jsonify({"error": "Missing theme or font"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
font_path = fonts_dir / font font_path = fonts_dir / font
if font_path.exists(): if font_path.exists():
font_path.unlink() font_path.unlink()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
return jsonify({"error": "Font not found"}), 404 return jsonify({"error": "Font not found"}), 404
# --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"])
def trigger_build():
"""
Validate site.yaml and run build.py.
Does NOT create zip here; zip is created on demand in download route.
"""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
output_folder = Path(__file__).resolve().parents[3] / "output"
if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f:
site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys
main_sections = list(site_data.keys())
for section in main_sections:
value = site_data.get(section)
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
elif isinstance(value, list):
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
for idx, item in enumerate(value):
if isinstance(item, dict):
for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
else:
if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
try:
subprocess.run(["python3", "build.py"], check=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"])
def download_output_zip():
"""
Create output zip on demand and send it to the user.
Zip is deleted after sending.
"""
output_folder = Path(__file__).resolve().parents[3] / "output"
zip_path = Path(__file__).resolve().parents[3] / "site_output.zip" # Store in lumeex/ root
# Create zip on demand
with zipfile.ZipFile(zip_path, "w") as zipf:
for root, dirs, files in os.walk(output_folder):
for file in files:
file_path = Path(root) / file
zipf.write(file_path, file_path.relative_to(output_folder))
@after_this_request
def remove_file(response):
try:
os.remove(zip_path)
except Exception:
pass
return response
return send_file(zip_path, as_attachment=True)
# --- Run server --- # --- Run server ---
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -10,12 +10,6 @@
<!-- Top bar --> <!-- Top bar -->
<div class="nav-bar"> <div class="nav-bar">
<div class="content-inner nav"> <div class="content-inner nav">
<div class="nav-cta">
<div class="arrow"></div>
<a class="button" href="#" target="_blank">
<span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span>
</a>
</div>
<input type="checkbox" id="nav-check"> <input type="checkbox" id="nav-check">
<div class="nav-header"> <div class="nav-header">
<div class="nav-title"> <div class="nav-title">
@ -31,9 +25,12 @@
</div> </div>
<div class="nav-links"> <div class="nav-links">
<ul class="nav-list"> <ul class="nav-list">
<li class="nav-item appear2"><a href="/site-info">Site info</a></li> <li class="nav-item"><a href="/gallery-editor">Gallery</a>
<li class="nav-item appear2"><a href="/theme-editor">Theme info</a></li> <li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item appear2"><a href="#">Gallery</a> <li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -73,6 +70,7 @@
</div> </div>
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script> <script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/upload.js') }}" defer></script> <script src="{{ url_for('static', filename='js/upload.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/build.js') }}" defer></script>
</div> </div>
<!-- Delete confirmation modal --> <!-- Delete confirmation modal -->
<div id="delete-modal" class="modal" style="display:none;"> <div id="delete-modal" class="modal" style="display:none;">
@ -86,5 +84,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
</body> </body>
</html> </html>

81
src/webui/js/build.js Normal file
View File

@ -0,0 +1,81 @@
/**
* Show a toast notification.
* @param {string} message - The message to display.
* @param {string} type - "success" or "error".
* @param {number} duration - Duration in ms.
*/
function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => container.removeChild(toast), 300);
}, duration);
}
document.addEventListener("DOMContentLoaded", () => {
// Get build button and modal elements
const buildBtn = document.getElementById("build-btn");
const buildModal = document.getElementById("build-success-modal");
const buildModalClose = document.getElementById("build-success-modal-close");
const downloadZipBtn = document.getElementById("download-zip-btn");
const zipLoader = document.getElementById("zip-loader");
// Handle build button click
if (buildBtn) {
buildBtn.addEventListener("click", async () => {
// Trigger build on backend
const res = await fetch("/api/build", { method: "POST" });
const result = await res.json();
if (result.status === "ok") {
// Show build success modal
if (buildModal) buildModal.style.display = "flex";
} else {
showToast(result.message || "❌ Build failed!", "error");
}
});
}
// Handle download zip button click
if (downloadZipBtn) {
downloadZipBtn.addEventListener("click", async () => {
if (zipLoader) zipLoader.style.display = "block";
downloadZipBtn.disabled = true;
// Request zip creation and download from backend
const res = await fetch("/download-output-zip", { method: "POST" });
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "site_output.zip";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
showToast("❌ Error creating ZIP", "error");
}
if (zipLoader) zipLoader.style.display = "none";
downloadZipBtn.disabled = false;
});
}
// Modal close logic
if (buildModal && buildModalClose) {
buildModalClose.onclick = () => {
buildModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === buildModal) {
buildModal.style.display = "none";
}
};
}
});

View File

@ -63,7 +63,7 @@ document.addEventListener("DOMContentLoaded", () => {
div.style.gap = "8px"; div.style.gap = "8px";
div.style.marginBottom = "6px"; div.style.marginBottom = "6px";
div.innerHTML = ` div.innerHTML = `
<textarea placeholder="Paragraph" style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea> <textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button> <button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
`; `;
ipList.appendChild(div); ipList.appendChild(div);
@ -129,9 +129,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (result.status === "ok") { if (result.status === "ok") {
if (thumbnailInput) thumbnailInput.value = result.filename; if (thumbnailInput) thumbnailInput.value = result.filename;
updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`); updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`);
showToast("Thumbnail uploaded!", "success"); showToast("Thumbnail uploaded!", "success");
} else { } else {
showToast("Error uploading thumbnail", "error"); showToast("Error uploading thumbnail", "error");
} }
}); });
} }
@ -159,9 +159,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (result.status === "ok") { if (result.status === "ok") {
if (thumbnailInput) thumbnailInput.value = ""; if (thumbnailInput) thumbnailInput.value = "";
updateThumbnailPreview(""); updateThumbnailPreview("");
showToast("Thumbnail removed!", "success"); showToast("Thumbnail removed!", "success");
} else { } else {
showToast("Error removing thumbnail", "error"); showToast("Error removing thumbnail", "error");
} }
deleteModal.style.display = "none"; deleteModal.style.display = "none";
}; };
@ -182,7 +182,7 @@ document.addEventListener("DOMContentLoaded", () => {
const res = await fetch("/api/theme/upload", { method: "POST", body: formData }); const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
const result = await res.json(); const result = await res.json();
if (result.status === "ok") { if (result.status === "ok") {
showToast("Theme uploaded!", "success"); showToast("Theme uploaded!", "success");
// Refresh theme select after upload // Refresh theme select after upload
fetch("/api/themes") fetch("/api/themes")
.then(res => res.json()) .then(res => res.json())
@ -196,7 +196,7 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}); });
} else { } else {
showToast("Error uploading theme", "error"); showToast("Error uploading theme", "error");
} }
}); });
} }
@ -311,6 +311,12 @@ document.addEventListener("DOMContentLoaded", () => {
updateMenuItemsFromInputs(); updateMenuItemsFromInputs();
updateIpParagraphsFromInputs(); updateIpParagraphsFromInputs();
// Check if thumbnail is set before saving (uploaded or present in input)
if (!thumbnailInput || !thumbnailInput.value) {
showToast("❌ Thumbnail is required.", "error");
return;
}
const build = { const build = {
theme: themeSelect ? themeSelect.value : "", theme: themeSelect ? themeSelect.value : "",
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),

View File

@ -10,12 +10,6 @@
<!-- Top bar --> <!-- Top bar -->
<div class="nav-bar"> <div class="nav-bar">
<div class="content-inner nav"> <div class="content-inner nav">
<div class="nav-cta">
<div class="arrow"></div>
<a class="button" href="#" target="_blank">
<span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span>
</a>
</div>
<input type="checkbox" id="nav-check"> <input type="checkbox" id="nav-check">
<div class="nav-header"> <div class="nav-header">
<div class="nav-title"> <div class="nav-title">
@ -31,9 +25,12 @@
</div> </div>
<div class="nav-links"> <div class="nav-links">
<ul class="nav-list"> <ul class="nav-list">
<li class="nav-item appear2"><a href="/site-info">Site info</a></li> <li class="nav-item"><a href="/gallery-editor">Gallery</a>
<li class="nav-item appear2"><a href="/theme-editor">Theme info</a></li> <li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item appear2"><a href="#">Gallery</a></li> <li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -81,9 +78,10 @@
<label>Instagram URL</label> <label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required> <input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label> <label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;" required> <input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button> <button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form"> <div class="thumbnail-form">
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;"> <img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button> <button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div> </div>
@ -120,15 +118,15 @@
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Hoster Name</label> <label>Hoster Name</label>
<input type="text" name="legals.hoster_name" placeholder="Name"> <input type="text" name="legals.hoster_name" placeholder="Name" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Hoster Address</label> <label>Hoster Address</label>
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country"> <input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Hoster Contact</label> <label>Hoster Contact</label>
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone"> <input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
</div> </div>
<div class="input-field" style="flex: 1 1 100%;"> <div class="input-field" style="flex: 1 1 100%;">
<label>Intellectual Property</label> <label>Intellectual Property</label>
@ -173,6 +171,17 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/site-info.js')}}"></script> <script src="{{ url_for('static', filename='js/site-info.js')}}"></script>
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
</body> </body>
</html> </html>

View File

@ -7,6 +7,7 @@ body {
color: #FBFBFB; color: #FBFBFB;
min-height: 100vh; min-height: 100vh;
margin:0px; margin:0px;
padding-top: 70px;
} }
.content-inner { .content-inner {
@ -256,12 +257,14 @@ h2 {
} }
.nav-bar { .nav-bar {
height: 70px; position: fixed;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 70px;
background-color: #0c0d0c29; background-color: #0c0d0c29;
position: relative; z-index: 1000;
z-index: 1; backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border-bottom: 1px solid #21212157; border-bottom: 1px solid #21212157;
} }
@ -287,6 +290,15 @@ h2 {
display: none; display: none;
} }
.nav-btn label {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
width: 32px;
height: 32px;
}
.nav > .nav-links { .nav > .nav-links {
display: inline; display: inline;
float: right; float: right;
@ -345,7 +357,7 @@ h2 {
font-weight: 700; font-weight: 700;
} }
.nav-cta > .button { .nav-bar .button {
padding: 10px 25px; padding: 10px 25px;
border-radius: 40px; border-radius: 40px;
margin: 15px 20px 15px 10px; margin: 15px 20px 15px 10px;
@ -356,17 +368,29 @@ h2 {
text-decoration: none; text-decoration: none;
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
border: none;
cursor: pointer;
} }
.nav-cta > .button:hover { .nav-bar .button:hover {
background: linear-gradient(135deg, #72d9ff, #26657e); background: linear-gradient(135deg, #72d9ff, #26657e);
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer;
} }
.nav-links > ul { .nav-links > ul {
display: inline-block; display: inline-block;
} }
.nav-btn span {
display: block;
height: 4px;
width: 28px;
background: #fff;
margin: 4px 0;
border-radius: 2px;
transition: all 0.3s;
}
/* --- Custom Upload Buttons --- */ /* --- Custom Upload Buttons --- */
.up-btn { .up-btn {
display: inline-block; display: inline-block;
@ -717,4 +741,4 @@ fieldset p {
#theme-editor-form button[type="button"]#choose-font-btn { #theme-editor-form button[type="button"]#choose-font-btn {
margin-top: 16px; margin-top: 16px;
} }

View File

@ -7,19 +7,13 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head> </head>
<body> <body>
<!-- Top bar --> <!-- Top bar -->
<div class="nav-bar"> <div class="nav-bar">
<div class="content-inner nav"> <div class="content-inner nav">
<div class="nav-cta">
<div class="arrow"></div>
<a class="button" href="/site-info">
<span id="step">← Back to Site Info</span>
</a>
</div>
<input type="checkbox" id="nav-check"> <input type="checkbox" id="nav-check">
<div class="nav-header"> <div class="nav-header">
<div class="nav-title"> <div class="nav-title">
<img src="../img/logo.svg"> <img src="{{ url_for('static', filename='img/logo.svg') }}">
</div> </div>
</div> </div>
<div class="nav-btn"> <div class="nav-btn">
@ -31,9 +25,12 @@
</div> </div>
<div class="nav-links"> <div class="nav-links">
<ul class="nav-list"> <ul class="nav-list">
<li class="nav-item appear2"><a href="/site-info">Site info</a></li> <li class="nav-item"><a href="/gallery-editor">Gallery</a>
<li class="nav-item appear2"><a href="/theme-editor">Theme editor</a></li> <li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item appear2"><a href="#">Gallery</a></li> <li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -197,6 +194,17 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script> <script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
</body> </body>
</html> </html>