# --- Imports --- import logging import yaml import subprocess import zipfile import os from pathlib import Path from flask import ( Flask, jsonify, request, send_from_directory, render_template, send_file, after_this_request ) from src.py.builder.gallery_builder import ( GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero ) from src.py.webui.upload import upload_bp # --- Logging configuration --- logging.basicConfig(level=logging.INFO, format="%(message)s") # --- Flask app setup --- VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION" with open(VERSION_FILE, "r") as vf: lumeex_version = vf.read().strip() WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates app = Flask( __name__, template_folder=WEBUI_PATH, static_folder=WEBUI_PATH, static_url_path="" ) # --- Config paths --- SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" app.config["PHOTOS_DIR"] = PHOTOS_DIR # --- Register upload blueprint --- app.register_blueprint(upload_bp) # --- Theme editor helper functions --- def get_theme_name(): """Get current theme name from site.yaml.""" site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml" with open(site_yaml_path, "r") as f: site_yaml = yaml.safe_load(f) return site_yaml.get("build", {}).get("theme", "modern") 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" with open(theme_yaml_path, "r") as f: return yaml.safe_load(f) 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" with open(theme_yaml_path, "w") as f: yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) 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" if not fonts_dir.exists(): return [] return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]] # --- ROUTES --- # --- Main page --- @app.route("/") def index(): return render_template("index.html") @app.context_processor def inject_version(): return dict(lumeex_version=lumeex_version) # --- Gallery & Hero API --- @app.route("/gallery-editor") def gallery_editor(): """Render gallery editor page.""" return render_template("gallery-editor/index.html") @app.route("/api/gallery", methods=["GET"]) def get_gallery(): """Get gallery images.""" data = load_yaml(GALLERY_YAML) return jsonify(data.get("gallery", {}).get("images", [])) @app.route("/api/hero", methods=["GET"]) def get_hero(): """Get hero images.""" data = load_yaml(GALLERY_YAML) return jsonify(data.get("hero", {}).get("images", [])) @app.route("/api/gallery/update", methods=["POST"]) def update_gallery_api(): """Update gallery images.""" images = request.json data = load_yaml(GALLERY_YAML) data["gallery"]["images"] = images save_yaml(data, GALLERY_YAML) return jsonify({"status": "ok"}) @app.route("/api/hero/update", methods=["POST"]) def update_hero_api(): """Update hero images.""" images = request.json data = load_yaml(GALLERY_YAML) data["hero"]["images"] = images save_yaml(data, GALLERY_YAML) return jsonify({"status": "ok"}) @app.route("/api/gallery/refresh", methods=["POST"]) def refresh_gallery(): """Refresh gallery images from disk.""" update_gallery() return jsonify({"status": "ok"}) @app.route("/api/hero/refresh", methods=["POST"]) def refresh_hero(): """Refresh hero images from disk.""" update_hero() return jsonify({"status": "ok"}) # --- Gallery & Hero photo deletion --- @app.route("/api/gallery/delete", methods=["POST"]) def delete_gallery_photo(): """Delete a gallery photo.""" data = request.json src = data.get("src") file_path = PHOTOS_DIR / "gallery" / src if file_path.exists(): file_path.unlink() return {"status": "ok"} return {"error": "❌ File not found"}, 404 @app.route("/api/hero/delete", methods=["POST"]) def delete_hero_photo(): """Delete a hero photo.""" data = request.json src = data.get("src") file_path = PHOTOS_DIR / "hero" / src if file_path.exists(): file_path.unlink() return {"status": "ok"} return {"error": "❌ File not found"}, 404 @app.route("/api/gallery/delete_all", methods=["POST"]) def delete_all_gallery_photos(): """Delete all gallery photos.""" gallery_dir = PHOTOS_DIR / "gallery" deleted = 0 for file in gallery_dir.glob("*"): if file.is_file(): file.unlink() deleted += 1 data = load_yaml(GALLERY_YAML) data["gallery"]["images"] = [] save_yaml(data, GALLERY_YAML) return jsonify({"status": "ok", "deleted": deleted}) @app.route("/api/hero/delete_all", methods=["POST"]) def delete_all_hero_photos(): """Delete all hero photos.""" hero_dir = PHOTOS_DIR / "hero" deleted = 0 for file in hero_dir.glob("*"): if file.is_file(): file.unlink() deleted += 1 data = load_yaml(GALLERY_YAML) data["hero"]["images"] = [] save_yaml(data, GALLERY_YAML) return jsonify({"status": "ok", "deleted": deleted}) # --- Serve photos --- @app.route("/photos/
/") def photos(section, filename): """Serve a photo from a section.""" return send_from_directory(PHOTOS_DIR / section, filename) @app.route("/photos/") def serve_photo(filename): """Serve a photo from the photos directory.""" photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos" return send_from_directory(photos_dir, filename) # --- Site info page & API --- @app.route("/site-info") def site_info(): """Render site info editor page.""" return render_template("site-info/index.html") @app.route("/api/site-info", methods=["GET"]) def get_site_info(): """Get site info YAML as JSON.""" with open(SITE_YAML, "r") as f: data = yaml.safe_load(f) return jsonify(data) @app.route("/api/site-info", methods=["POST"]) def update_site_info(): """Update site info YAML.""" data = request.json with open(SITE_YAML, "w") as f: yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok"}) # --- Theme management --- @app.route("/api/themes") def list_themes(): """List available themes.""" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" themes = [d.name for d in themes_dir.iterdir() if d.is_dir()] return jsonify(themes) # --- Thumbnail upload/remove --- @app.route("/api/thumbnail/upload", methods=["POST"]) def upload_thumbnail(): """Upload thumbnail image and update site.yaml.""" PHOTOS_DIR = app.config["PHOTOS_DIR"] file = request.files.get("file") if not file: return {"error": "❌ No file provided"}, 400 filename = "thumbnail.png" file.save(PHOTOS_DIR / filename) with open(SITE_YAML, "r") as f: data = yaml.safe_load(f) data.setdefault("social", {})["thumbnail"] = filename with open(SITE_YAML, "w") as f: yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok", "filename": filename}) @app.route("/api/thumbnail/remove", methods=["POST"]) def remove_thumbnail(): """Remove thumbnail image and update site.yaml.""" PHOTOS_DIR = app.config["PHOTOS_DIR"] thumbnail_path = PHOTOS_DIR / "thumbnail.png" if thumbnail_path.exists(): thumbnail_path.unlink() with open(SITE_YAML, "r") as f: data = yaml.safe_load(f) if "social" in data and "thumbnail" in data["social"]: data["social"]["thumbnail"] = "" with open(SITE_YAML, "w") as f: yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok"}) # --- Theme upload --- @app.route("/api/theme/upload", methods=["POST"]) def upload_theme(): """Upload a custom theme folder.""" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" files = request.files.getlist("files") if not files: return jsonify({"error": "❌ No files provided"}), 400 first_path = files[0].filename folder_name = first_path.split("/")[0] if "/" in first_path else "custom" theme_folder = themes_dir / folder_name theme_folder.mkdir(parents=True, exist_ok=True) for file in files: rel_path = Path(file.filename) dest_path = theme_folder / rel_path.relative_to(folder_name) dest_path.parent.mkdir(parents=True, exist_ok=True) file.save(dest_path) return jsonify({"status": "ok", "theme": folder_name}) @app.route("/api/theme/remove", methods=["POST"]) def remove_theme(): """Remove a custom theme folder.""" data = request.get_json() theme_name = data.get("theme") if not theme_name: return jsonify({"error": "❌ Missing theme"}), 400 themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" theme_folder = themes_dir / theme_name if not theme_folder.exists() or not theme_folder.is_dir(): return jsonify({"error": "❌ Theme not found"}), 404 # Prevent removing default themes if theme_name in ["modern", "classic"]: return jsonify({"error": "❌ Cannot remove default theme"}), 400 # Remove folder and all contents import shutil shutil.rmtree(theme_folder) return jsonify({"status": "ok"}) # --- Theme editor page & API --- @app.route("/theme-editor") def theme_editor(): """Render theme editor page.""" return render_template("theme-editor/index.html") @app.route("/api/theme-info", methods=["GET", "POST"]) def api_theme_info(): """Get or update theme.yaml for current theme.""" theme_name = get_theme_name() if request.method == "GET": theme_yaml = get_theme_yaml(theme_name) google_fonts = theme_yaml.get("google_fonts", []) return jsonify({ "theme_name": theme_name, "theme_yaml": theme_yaml, "google_fonts": google_fonts }) else: data = request.get_json() theme_yaml = data.get("theme_yaml") theme_name = data.get("theme_name", theme_name) save_theme_yaml(theme_name, theme_yaml) return jsonify({"status": "ok"}) @app.route("/api/theme-google-fonts", methods=["POST"]) def update_theme_google_fonts(): """Update only google_fonts in theme.yaml for current theme.""" data = request.get_json() theme_name = data.get("theme_name") google_fonts = data.get("google_fonts", []) theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" with open(theme_yaml_path, "r") as f: theme_yaml = yaml.safe_load(f) theme_yaml["google_fonts"] = google_fonts with open(theme_yaml_path, "w") as f: yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok"}) @app.route("/api/local-fonts") def api_local_fonts(): """List local fonts for a theme.""" theme_name = request.args.get("theme") fonts = get_local_fonts(theme_name) return jsonify(fonts) # --- Favicon upload/remove --- @app.route("/api/favicon/upload", methods=["POST"]) def upload_favicon(): """Upload favicon for a theme.""" theme_name = request.form.get("theme") file = request.files.get("file") if not file or not theme_name: return jsonify({"error": "❌ Missing file or theme"}), 400 ext = Path(file.filename).suffix.lower() if ext not in [".png", ".jpg", ".jpeg", ".ico"]: return jsonify({"error": "❌ Invalid file type"}), 400 filename = "favicon" + ext theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name file.save(theme_dir / filename) theme_yaml_path = theme_dir / "theme.yaml" with open(theme_yaml_path, "r") as f: theme_yaml = yaml.safe_load(f) theme_yaml.setdefault("favicon", {})["path"] = filename with open(theme_yaml_path, "w") as f: yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok", "filename": filename}) @app.route("/api/favicon/remove", methods=["POST"]) def remove_favicon(): """Remove favicon for a theme.""" data = request.get_json() theme_name = data.get("theme") if not theme_name: return jsonify({"error": "❌ Missing theme"}), 400 theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name for ext in [".png", ".jpg", ".jpeg", ".ico"]: favicon_path = theme_dir / f"favicon{ext}" if favicon_path.exists(): favicon_path.unlink() theme_yaml_path = theme_dir / "theme.yaml" with open(theme_yaml_path, "r") as f: theme_yaml = yaml.safe_load(f) if "favicon" in theme_yaml: theme_yaml["favicon"]["path"] = "" with open(theme_yaml_path, "w") as f: yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok"}) # --- Serve theme assets --- @app.route("/themes//") def serve_theme_asset(theme, filename): """Serve a theme asset file.""" theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme return send_from_directory(theme_dir, filename) # --- Font upload/remove --- @app.route("/api/font/upload", methods=["POST"]) def upload_font(): """Upload a font file for a theme.""" theme_name = request.form.get("theme") file = request.files.get("file") if not file or not theme_name: return jsonify({"error": "❌ Missing theme or font"}), 400 ext = Path(file.filename).suffix.lower() if ext not in [".woff", ".woff2"]: return jsonify({"error": "❌ Invalid font file type"}), 400 fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir.mkdir(parents=True, exist_ok=True) file.save(fonts_dir / file.filename) return jsonify({"status": "ok", "filename": file.filename}) @app.route("/api/font/remove", methods=["POST"]) def remove_font(): """Remove a font file for a theme.""" data = request.get_json() theme_name = data.get("theme") font = data.get("font") if not theme_name or not font: return jsonify({"error": "❌ Missing theme or font"}), 400 fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" font_path = fonts_dir / font if font_path.exists(): font_path.unlink() return jsonify({"status": "ok"}) 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 --- if __name__ == "__main__": logging.info("Starting WebUI at http://0.0.0.0:5000") app.run(host="0.0.0.0", port=5000, debug=True)