diff --git a/src/py/webui/webui.py b/src/py/webui/webui.py index 3425b0c..3a12e31 100644 --- a/src/py/webui/webui.py +++ b/src/py/webui/webui.py @@ -1,7 +1,14 @@ +# --- 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 +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 ) @@ -19,61 +26,64 @@ app = Flask( static_url_path="" ) +# --- Config paths --- SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" - -# --- Photos directory (configurable) --- PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" app.config["PHOTOS_DIR"] = PHOTOS_DIR # --- Register upload blueprint --- app.register_blueprint(upload_bp) -# --- Helper functions for theme editor --- +# --- 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 full filenames, not just stem 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("/") def index(): - """Serve the main HTML page.""" return render_template("index.html") +# --- Gallery & Hero API --- @app.route("/api/gallery", methods=["GET"]) def get_gallery(): - """Return JSON list of gallery images from YAML.""" + """Get gallery images.""" data = load_yaml(GALLERY_YAML) return jsonify(data.get("gallery", {}).get("images", [])) @app.route("/api/hero", methods=["GET"]) def get_hero(): - """Return JSON list of hero images from YAML.""" + """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 in YAML from frontend JSON.""" + """Update gallery images.""" images = request.json data = load_yaml(GALLERY_YAML) data["gallery"]["images"] = images @@ -82,7 +92,7 @@ def update_gallery_api(): @app.route("/api/hero/update", methods=["POST"]) def update_hero_api(): - """Update hero images in YAML from frontend JSON.""" + """Update hero images.""" images = request.json data = load_yaml(GALLERY_YAML) data["hero"]["images"] = images @@ -91,49 +101,48 @@ def update_hero_api(): @app.route("/api/gallery/refresh", methods=["POST"]) def refresh_gallery(): - """Refresh gallery YAML from photos/gallery folder.""" + """Refresh gallery images from disk.""" update_gallery() return jsonify({"status": "ok"}) @app.route("/api/hero/refresh", methods=["POST"]) def refresh_hero(): - """Refresh hero YAML from photos/hero folder.""" + """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 from disk and return status.""" + """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 + return {"error": "❌ File not found"}, 404 @app.route("/api/hero/delete", methods=["POST"]) def delete_hero_photo(): - """Delete a hero photo from disk and return status.""" + """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 + return {"error": "❌ File not found"}, 404 @app.route("/api/gallery/delete_all", methods=["POST"]) def delete_all_gallery_photos(): - """Delete all gallery photos from disk and YAML.""" + """Delete all gallery photos.""" gallery_dir = PHOTOS_DIR / "gallery" deleted = 0 - # Remove all files in gallery folder for file in gallery_dir.glob("*"): if file.is_file(): file.unlink() deleted += 1 - # Clear YAML gallery images data = load_yaml(GALLERY_YAML) data["gallery"]["images"] = [] save_yaml(data, GALLERY_YAML) @@ -141,68 +150,69 @@ def delete_all_gallery_photos(): @app.route("/api/hero/delete_all", methods=["POST"]) def delete_all_hero_photos(): - """Delete all hero photos from disk and YAML.""" + """Delete all hero photos.""" hero_dir = PHOTOS_DIR / "hero" deleted = 0 - # Remove all files in hero folder for file in hero_dir.glob("*"): if file.is_file(): file.unlink() deleted += 1 - # Clear YAML hero images 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 uploaded photos from disk for a specific section.""" + """Serve a photo from a section.""" return send_from_directory(PHOTOS_DIR / section, filename) @app.route("/photos/") 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" return send_from_directory(photos_dir, filename) +# --- Site info page & API --- @app.route("/site-info") def site_info(): - """Serve the site info editor page.""" + """Render site info editor page.""" return render_template("site-info/index.html") @app.route("/api/site-info", methods=["GET"]) def get_site_info(): - """Return the site info YAML as JSON.""" + """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 the site info YAML from frontend JSON.""" + """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 (folders in config/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 a thumbnail image and update site.yaml.""" + """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 + return {"error": "❌ No file provided"}, 400 filename = "thumbnail.png" file.save(PHOTOS_DIR / filename) - # Update site.yaml with open(SITE_YAML, "r") as f: data = yaml.safe_load(f) data.setdefault("social", {})["thumbnail"] = filename @@ -212,13 +222,11 @@ def upload_thumbnail(): @app.route("/api/thumbnail/remove", methods=["POST"]) def remove_thumbnail(): - """Remove the thumbnail image and update site.yaml.""" + """Remove thumbnail image and update site.yaml.""" PHOTOS_DIR = app.config["PHOTOS_DIR"] thumbnail_path = PHOTOS_DIR / "thumbnail.png" - # Remove thumbnail file if exists if thumbnail_path.exists(): thumbnail_path.unlink() - # Update site.yaml to remove thumbnail key with open(SITE_YAML, "r") as f: data = yaml.safe_load(f) 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) return jsonify({"status": "ok"}) +# --- Theme upload --- @app.route("/api/theme/upload", methods=["POST"]) 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" files = request.files.getlist("files") if not files: - return jsonify({"error": "No files provided"}), 400 - # Get folder name from first file's webkitRelativePath + 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 @@ -246,14 +254,15 @@ def upload_theme(): file.save(dest_path) return jsonify({"status": "ok", "theme": folder_name}) -# --- Theme Editor API --- +# --- Theme editor page & API --- @app.route("/theme-editor") def theme_editor(): - """Serve the theme editor page.""" + """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) @@ -272,25 +281,25 @@ def api_theme_info(): @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 to theme folder and update theme.yaml.""" + """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 + 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 + 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) - # Update theme.yaml theme_yaml_path = theme_dir / "theme.yaml" with open(theme_yaml_path, "r") as f: theme_yaml = yaml.safe_load(f) @@ -301,18 +310,16 @@ def upload_favicon(): @app.route("/api/favicon/remove", methods=["POST"]) def remove_favicon(): - """Remove favicon from theme folder and update theme.yaml.""" + """Remove favicon for a theme.""" data = request.get_json() theme_name = data.get("theme") 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 - # Remove favicon file for ext in [".png", ".jpg", ".jpeg", ".ico"]: favicon_path = theme_dir / f"favicon{ext}" if favicon_path.exists(): favicon_path.unlink() - # Update theme.yaml theme_yaml_path = theme_dir / "theme.yaml" with open(theme_yaml_path, "r") as 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) 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 to the theme's fonts folder (only .woff/.woff2 allowed).""" + """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 file or theme"}), 400 + 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 + 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) @@ -344,18 +354,90 @@ def upload_font(): @app.route("/api/font/remove", methods=["POST"]) def remove_font(): - """Remove a font file from the theme's fonts folder.""" + """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 + 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 + 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__": diff --git a/src/webui/index.html b/src/webui/gallery-editor/index.html similarity index 75% rename from src/webui/index.html rename to src/webui/gallery-editor/index.html index 6e7727b..29ef972 100644 --- a/src/webui/index.html +++ b/src/webui/gallery-editor/index.html @@ -10,12 +10,6 @@ + + \ No newline at end of file diff --git a/src/webui/js/build.js b/src/webui/js/build.js new file mode 100644 index 0000000..b0510ab --- /dev/null +++ b/src/webui/js/build.js @@ -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"; + } + }; + } +}); \ No newline at end of file diff --git a/src/webui/js/site-info.js b/src/webui/js/site-info.js index 34c2838..7a79439 100644 --- a/src/webui/js/site-info.js +++ b/src/webui/js/site-info.js @@ -63,7 +63,7 @@ document.addEventListener("DOMContentLoaded", () => { div.style.gap = "8px"; div.style.marginBottom = "6px"; div.innerHTML = ` - + `; ipList.appendChild(div); @@ -129,9 +129,9 @@ document.addEventListener("DOMContentLoaded", () => { if (result.status === "ok") { if (thumbnailInput) thumbnailInput.value = result.filename; updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`); - showToast("Thumbnail uploaded!", "success"); + showToast("✅ Thumbnail uploaded!", "success"); } else { - showToast("Error uploading thumbnail", "error"); + showToast("❌ Error uploading thumbnail", "error"); } }); } @@ -159,9 +159,9 @@ document.addEventListener("DOMContentLoaded", () => { if (result.status === "ok") { if (thumbnailInput) thumbnailInput.value = ""; updateThumbnailPreview(""); - showToast("Thumbnail removed!", "success"); + showToast("✅ Thumbnail removed!", "success"); } else { - showToast("Error removing thumbnail", "error"); + showToast("❌ Error removing thumbnail", "error"); } deleteModal.style.display = "none"; }; @@ -182,7 +182,7 @@ document.addEventListener("DOMContentLoaded", () => { const res = await fetch("/api/theme/upload", { method: "POST", body: formData }); const result = await res.json(); if (result.status === "ok") { - showToast("Theme uploaded!", "success"); + showToast("✅ Theme uploaded!", "success"); // Refresh theme select after upload fetch("/api/themes") .then(res => res.json()) @@ -196,7 +196,7 @@ document.addEventListener("DOMContentLoaded", () => { }); }); } else { - showToast("Error uploading theme", "error"); + showToast("❌ Error uploading theme", "error"); } }); } @@ -311,6 +311,12 @@ document.addEventListener("DOMContentLoaded", () => { updateMenuItemsFromInputs(); 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 = { theme: themeSelect ? themeSelect.value : "", convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), diff --git a/src/webui/site-info/index.html b/src/webui/site-info/index.html index 860f52d..f632e07 100644 --- a/src/webui/site-info/index.html +++ b/src/webui/site-info/index.html @@ -10,12 +10,6 @@ + + + \ No newline at end of file