diff --git a/Dockerfile b/Dockerfile index f49cf44..4f9ba87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,8 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +COPY build.py gallery.py VERSION /app/ COPY ./src/ ./src/ -COPY ./build.py ./build.py -COPY ./gallery.py ./gallery.py COPY ./config /app/default COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/README.MD b/README.MD index 7a92e40..6f2652a 100644 --- a/README.MD +++ b/README.MD @@ -18,7 +18,7 @@ The project includes two thoughtfully designed themes—one modern, one minimali - **Typewriter** — [View Demo](https://typewriter.djeex.fr) > [!NOTE] -> This GitHub repository is a mirror of the primary source at [git.djeex.fr/Djeex/lumeex](https://git.djeex.fr/Djeex/lumeex). The main repository includes the full history, releases, and bug-checking assisted by an LLM. +> _This GitHub repository is a mirror of the primary source at [git.djeex.fr/Djeex/lumeex](https://git.djeex.fr/Djeex/lumeex). The main repository includes the full history and releases_. ## 📌 Table of Contents @@ -41,20 +41,23 @@ The project includes two thoughtfully designed themes—one modern, one minimali - Typewriter — [Demo](https://typewriter.djeex.fr) - Supports Google Fonts and locally hosted fonts -### No-Code Builder (YAML Based) +### No-Code Builder (WebUI Manager) -- Configure site info, SEO, colors, fonts, and more through simple YAML files -- Reference and tag photos without any coding required -- *(Optional)* Automatically update photo references via script +
+ Lumeex Screenshot +
-### Simple Build Process - -- Compiles static site from YAML configuration files (themes, templates, fonts, colors) +- Configure site info, SEO, colors, fonts, and more through a simple convenient WebUI +- Add and tag your photo photos without any coding required - Converts favicon automatically to all required formats - Resizes social sharing thumbnails - *(Optional)* Automatically resizes photos to a maximum width of 1140px -- *(Optional)* Converts images to WebP format for optimized performance -- Outputs a complete static website ready to deploy on any web server +- *(Optional)* Converts images to WebP format for optimized performance +- Build your static site in one click and get a zip archive or an output folder, ready to deploy to your preferred webserver + +### Don't want a WebUI ? + +- CLI process is documented ## 🐳 Docker or 🐍 Python Installation For comprehensive documentation on installation, configuration options, customization, and demos, please visit: diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..359a5b9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0.0 \ No newline at end of file diff --git a/build.py b/build.py index 423d785..418f3fe 100644 --- a/build.py +++ b/build.py @@ -1,5 +1,5 @@ import logging -from src.py.site_builder import build +from src.py.builder.site_builder import build if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(message)s") diff --git a/config/site.yaml b/config/site.yaml index b3211f4..166b129 100644 --- a/config/site.yaml +++ b/config/site.yaml @@ -24,6 +24,8 @@ footer: # Build parameters build: theme: modern # choose a theme in config/theme folder + convert_images: true # true to enable image conversion + resize_images: true # true to enable image resizing # Change this by your legals legals: diff --git a/config/themes/modern/theme.yaml b/config/themes/modern/theme.yaml index 3f331f5..26cfa8a 100644 --- a/config/themes/modern/theme.yaml +++ b/config/themes/modern/theme.yaml @@ -8,8 +8,8 @@ colors: secondary: '#00b0f0' accent: '#ffc700' text_dark: '#616161' - background: '#fff' - browser_color: '#fff' + background: '#ffffff' + browser_color: '#ffffff' favicon: path: favicon.png google_fonts: diff --git a/config/themes/typewriter/theme.yaml b/config/themes/typewriter/theme.yaml index 7a30379..546e89d 100644 --- a/config/themes/typewriter/theme.yaml +++ b/config/themes/typewriter/theme.yaml @@ -8,8 +8,8 @@ colors: secondary: '#00b0f0' accent: '#ffc700' text_dark: '#616161' - background: '#fff' - browser_color: '#fff' + background: '#ffffff' + browser_color: '#ffffff' favicon: path: favicon.png fonts: diff --git a/docker/.sh/entrypoint.sh b/docker/.sh/entrypoint.sh index 4cb60f2..77d9eb2 100644 --- a/docker/.sh/entrypoint.sh +++ b/docker/.sh/entrypoint.sh @@ -43,16 +43,24 @@ start_server() { cat /tmp/build_logs_fifo >&2 & cat /tmp/build_logs_fifo2 >&2 & - echo "Starting HTTP server on port 3000..." + echo "Starting preview HTTP server on port 3000..." python3 -u -m http.server 3000 -d /app/output & SERVER_PID=$! - trap "echo 'Stopping server...'; kill -TERM $SERVER_PID 2>/dev/null; wait $SERVER_PID; exit 0" SIGINT SIGTERM + + echo "Starting Lumeex Flask webui..." + python3 -u -m src.py.webui.webui & + WEBUI_PID=$! + + trap "echo 'Stopping servers...'; kill -TERM $SERVER_PID $WEBUI_PID 2>/dev/null; wait $SERVER_PID $WEBUI_PID; exit 0" SIGINT SIGTERM + wait $SERVER_PID + wait $WEBUI_PID } +VERSION=$(cat VERSION) if [ $# -eq 0 ]; then echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}" - echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version 1.3.2${NC} ${CYAN}│${NC}" + echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version ${VERSION}${NC} ${CYAN}│${NC}" echo -e "${CYAN}├───────────────────────────────────────────┤${NC}" echo -e "${CYAN}│${NC} Source: https://git.djeex.fr/Djeex/lumeex ${CYAN}│${NC}" echo -e "${CYAN}│${NC} Mirror: https://github.com/Djeex/lumeex ${CYAN}│${NC}" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index aab8432..f48000c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -7,4 +7,5 @@ services: - ../output:/app/output # mount output directory ports: - "3000:3000" + - "5000:5000" \ No newline at end of file diff --git a/gallery.py b/gallery.py index 18efc02..045ecae 100644 --- a/gallery.py +++ b/gallery.py @@ -1,5 +1,5 @@ import logging -from src.py.gallery_builder import update_gallery, update_hero +from src.py.builder.gallery_builder import update_gallery, update_hero if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(message)s") diff --git a/illustration/lumeex-webui.png b/illustration/lumeex-webui.png new file mode 100644 index 0000000..198ed5f Binary files /dev/null and b/illustration/lumeex-webui.png differ diff --git a/requirements.txt b/requirements.txt index 96b5dca..58efc72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyyaml -pillow \ No newline at end of file +pillow +flask \ No newline at end of file diff --git a/src/py/__init__.py b/src/py/builder/__init__.py similarity index 100% rename from src/py/__init__.py rename to src/py/builder/__init__.py diff --git a/src/py/css_generator.py b/src/py/builder/css_generator.py similarity index 100% rename from src/py/css_generator.py rename to src/py/builder/css_generator.py diff --git a/src/py/gallery_builder.py b/src/py/builder/gallery_builder.py similarity index 100% rename from src/py/gallery_builder.py rename to src/py/builder/gallery_builder.py diff --git a/src/py/html_generator.py b/src/py/builder/html_generator.py similarity index 100% rename from src/py/html_generator.py rename to src/py/builder/html_generator.py diff --git a/src/py/image_processor.py b/src/py/builder/image_processor.py similarity index 100% rename from src/py/image_processor.py rename to src/py/builder/image_processor.py diff --git a/src/py/site_builder.py b/src/py/builder/site_builder.py similarity index 98% rename from src/py/site_builder.py rename to src/py/builder/site_builder.py index f8996f1..9502ad6 100644 --- a/src/py/site_builder.py +++ b/src/py/builder/site_builder.py @@ -21,12 +21,14 @@ STYLE_DIR = SRC_DIR / "src/public/style" GALLERY_FILE = SRC_DIR / "config/gallery.yaml" SITE_FILE = SRC_DIR / "config/site.yaml" THEMES_DIR = SRC_DIR / "config/themes" +VERSION_FILE = SRC_DIR / "VERSION" +with open(VERSION_FILE, "r") as vf: + build_version = vf.read().strip() def build(): - build_version = "v1.3.2" logging.info("\n") logging.info("=" * 24) - logging.info(f"🚀 Lumeex builder {build_version}") + logging.info(f"🚀 Lumeex builder v{build_version}") logging.info("=" * 24) logging.info("\n === Starting build === ") ensure_dir(BUILD_DIR) diff --git a/src/py/utils.py b/src/py/builder/utils.py similarity index 100% rename from src/py/utils.py rename to src/py/builder/utils.py diff --git a/src/py/webui/__init__.py b/src/py/webui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/py/webui/upload.py b/src/py/webui/upload.py new file mode 100644 index 0000000..8a3a246 --- /dev/null +++ b/src/py/webui/upload.py @@ -0,0 +1,66 @@ +import logging +from pathlib import Path +from flask import Blueprint, request, current_app +from werkzeug.utils import secure_filename +from src.py.builder.gallery_builder import update_gallery, update_hero + +# --- Create Flask blueprint for upload routes --- +upload_bp = Blueprint("upload", __name__) + +# --- Allowed file types --- +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"} + +def allowed_file(filename: str) -> bool: + """Check if the uploaded file has an allowed extension.""" + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + +def save_uploaded_file(file, folder: Path): + """Save an uploaded file to the specified folder.""" + folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists + filename = secure_filename(file.filename) # Sanitize filename + file.save(folder / filename) # Save to disk + logging.info(f"[✓] Uploaded {filename} to {folder}") + return filename + +@upload_bp.route("/api/
/upload", methods=["POST"]) +def upload_photo(section: str): + """ + Handle file uploads for gallery or hero section. + Accepts multiple files under 'files'. + """ + # Validate section + if section not in ["gallery", "hero"]: + return {"error": "Invalid section"}, 400 + + # Check if files are provided + if "files" not in request.files: + return {"error": "No files provided"}, 400 + + files = request.files.getlist("files") + if not files: + return {"error": "No selected files"}, 400 + + # Get photos directory from app config + PHOTOS_DIR = current_app.config.get("PHOTOS_DIR") + if not PHOTOS_DIR: + return {"error": "Server misconfiguration"}, 500 + + folder = PHOTOS_DIR / section # Target folder + uploaded = [] + + # Save each valid file + for file in files: + if file and allowed_file(file.filename): + filename = save_uploaded_file(file, folder) + uploaded.append(filename) + + # Update YAML if any files were uploaded + if uploaded: + if section == "gallery": + update_gallery() + else: + update_hero() + return {"status": "ok", "uploaded": uploaded} + + return {"error": "No valid files uploaded"}, 400 + diff --git a/src/py/webui/webui.py b/src/py/webui/webui.py new file mode 100644 index 0000000..75969ac --- /dev/null +++ b/src/py/webui/webui.py @@ -0,0 +1,491 @@ +# --- 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) \ No newline at end of file diff --git a/src/webui/favicon.ico b/src/webui/favicon.ico new file mode 100644 index 0000000..7fabbb1 Binary files /dev/null and b/src/webui/favicon.ico differ diff --git a/src/webui/gallery-editor/index.html b/src/webui/gallery-editor/index.html new file mode 100644 index 0000000..329a65c --- /dev/null +++ b/src/webui/gallery-editor/index.html @@ -0,0 +1,70 @@ + +{% extends "template/base.html" %} + +{% block title %}Lumeex - Gallery Editor{% endblock %} + +{% block content %} + +

Gallery editor

+ + +
+

Title Carrousel

+

Select photos to display in the Title Carrousel

+
+ + +
+ +
+
+ + +
+

Gallery

+

Select and tags photos to display in the Gallery

+
+ + +
+ + +
+ +
+

Steps

+

Follow the steps to generate your static gallery

+ +
+ + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/src/webui/img/favicon.svg b/src/webui/img/favicon.svg new file mode 100644 index 0000000..b3d40d1 --- /dev/null +++ b/src/webui/img/favicon.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/webui/img/gitea.svg b/src/webui/img/gitea.svg new file mode 100644 index 0000000..9547800 --- /dev/null +++ b/src/webui/img/gitea.svg @@ -0,0 +1,2 @@ + +Gitea icon \ No newline at end of file diff --git a/src/webui/img/github.svg b/src/webui/img/github.svg new file mode 100644 index 0000000..c8db7de --- /dev/null +++ b/src/webui/img/github.svg @@ -0,0 +1,19 @@ + + + + + github [#142] + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/webui/img/logo.svg b/src/webui/img/logo.svg new file mode 100644 index 0000000..2425d77 --- /dev/null +++ b/src/webui/img/logo.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/webui/index.html b/src/webui/index.html new file mode 100644 index 0000000..2198926 --- /dev/null +++ b/src/webui/index.html @@ -0,0 +1,31 @@ +{% extends "template/base.html" %} + +{% block title %}Lumeex{% endblock %} + +{% block content %} + +

Static Gallery Generator

+

Use this generator to create a static gallery from your photos. Then, upload the static files to your preferred web server.

+ + +
+

Steps

+

Follow the steps to generate your static gallery

+
+ +
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ 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..91022da --- /dev/null +++ b/src/webui/js/build.js @@ -0,0 +1,106 @@ +/** + * Show a toast notification. + * @param {string} message - The message to display. + * @param {string} type - "success" or "error". + * @param {number} duration - Duration in ms. + */ + +// --- Loader helpers --- +function showLoader(text = "Uploading...") { + const loader = document.getElementById("global-loader"); + if (loader) { + loader.classList.add("active"); + document.getElementById("loader-text").textContent = text; + } +} +function hideLoader() { + const loader = document.getElementById("global-loader"); + if (loader) loader.classList.remove("active"); +} + +// --- Toast helpers --- +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 stepperBuildBtn = document.getElementById("stepper-build"); // Added for stepper build button + 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"); + + // Build action handler + async function handleBuildClick() { + showLoader("Building static site..."); + // Trigger build on backend + const res = await fetch("/api/build", { method: "POST" }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + // Show build success modal + if (buildModal) buildModal.style.display = "flex"; + } else { + showToast(result.message || "❌ Build failed!", "error"); + } + } + + // Handle build button click + if (buildBtn) { + buildBtn.addEventListener("click", handleBuildClick); + } + // Handle stepper-build button click + if (stepperBuildBtn) { + stepperBuildBtn.addEventListener("click", handleBuildClick); + } + + // 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/gallery-editor.js b/src/webui/js/gallery-editor.js new file mode 100644 index 0000000..640e567 --- /dev/null +++ b/src/webui/js/gallery-editor.js @@ -0,0 +1,438 @@ +// --- Arrays to store gallery and hero images --- +let galleryImages = []; +let heroImages = []; +let allTags = []; // global tag list + +// --- Load images from server on page load --- +async function loadData() { + try { + const galleryRes = await fetch('/api/gallery'); + galleryImages = await galleryRes.json(); + updateAllTags(); + renderGallery(); + + const heroRes = await fetch('/api/hero'); + heroImages = await heroRes.json(); + renderHero(); + } catch(err) { + console.error(err); + showToast("Error loading images!", "error"); + } +} + +// --- Update global tag list from galleryImages --- +function updateAllTags() { + allTags = []; + galleryImages.forEach(img => { + if (img.tags) img.tags.forEach(t => { + if (!allTags.includes(t)) allTags.push(t); + }); + }); +} + +// --- Render gallery images with tags and delete buttons --- +function renderGallery() { + const container = document.getElementById('gallery'); + container.innerHTML = ''; + galleryImages.forEach((img, i) => { + const div = document.createElement('div'); + div.className = 'photo flex-item flex-column'; + div.innerHTML = ` +
+ +
+
+
+
+ +
+
+
+ `; + container.appendChild(div); + + renderTags(i, img.tags || []); + }); + + // Show/hide Remove All button + const removeAllBtn = document.getElementById('remove-all-gallery'); + if (removeAllBtn) { + removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none'; + } +} + +// --- Render tags for a single image --- +function renderTags(imgIndex, tags) { + const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`); + const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`); + + tagsDisplay.innerHTML = ''; + inputContainer.innerHTML = ''; + + tags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'tag'; + span.textContent = tag; + + const remove = document.createElement('span'); + remove.className = 'remove-tag'; + remove.textContent = '×'; + remove.onclick = () => { + tags.splice(tags.indexOf(tag), 1); + updateTags(imgIndex, tags); + renderTags(imgIndex, tags); + }; + + span.appendChild(remove); + tagsDisplay.appendChild(span); + }); + + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Add tag...'; + inputContainer.appendChild(input); + + // --- Validate button --- + const validateBtn = document.createElement('button'); + validateBtn.textContent = '✔️'; + validateBtn.className = 'validate-tag-btn'; + validateBtn.style.display = 'none'; // hidden by default + validateBtn.style.marginLeft = '4px'; + inputContainer.appendChild(validateBtn); + + const suggestionBox = document.createElement('ul'); + suggestionBox.className = 'suggestions'; + inputContainer.appendChild(suggestionBox); + + let selectedIndex = -1; + + const addTag = (tag) => { + tag = tag.trim(); + if (!tag) return; + if (!tags.includes(tag)) tags.push(tag); + updateTags(imgIndex, tags); + renderTags(imgIndex, tags); + }; + + const updateSuggestions = () => { + const value = input.value.toLowerCase(); + + const allTagsFlat = galleryImages.flatMap(img => img.tags || []); + const tagCount = {}; + allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1); + + const allTagsSorted = Object.keys(tagCount) + .sort((a, b) => tagCount[b] - tagCount[a]); + + const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t)); + + suggestionBox.innerHTML = ''; + selectedIndex = -1; + + if (suggestions.length) { + suggestionBox.style.display = 'block'; + suggestions.forEach((s, idx) => { + const li = document.createElement('li'); + li.style.fontStyle = 'italic'; + li.style.textAlign = 'left'; + + const boldPart = `${s.substring(0, input.value.length)}`; + const rest = s.substring(input.value.length); + li.innerHTML = boldPart + rest; + + li.addEventListener('mousedown', (e) => { + e.preventDefault(); + addTag(s); + input.value = ''; + input.focus(); + updateSuggestions(); + }); + + li.onmouseover = () => selectedIndex = idx; + suggestionBox.appendChild(li); + }); + } else { + suggestionBox.style.display = 'none'; + } + }; + + input.addEventListener('input', () => { + updateSuggestions(); + validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none'; + }); + input.addEventListener('focus', () => { + updateSuggestions(); + validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none'; + }); + + input.addEventListener('keydown', (e) => { + const items = suggestionBox.querySelectorAll('li'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!items.length) return; + selectedIndex = (selectedIndex + 1) % items.length; + items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!items.length) return; + selectedIndex = (selectedIndex - 1 + items.length) % items.length; + items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && items[selectedIndex]) { + addTag(items[selectedIndex].textContent); + } else { + addTag(input.value); + } + input.value = ''; + updateSuggestions(); + validateBtn.style.display = 'none'; + } else if ([' ', ','].includes(e.key)) { + e.preventDefault(); + addTag(input.value); + input.value = ''; + updateSuggestions(); + validateBtn.style.display = 'none'; + } + }); + + input.addEventListener('blur', () => { + setTimeout(() => { + suggestionBox.style.display = 'none'; + input.value = ''; + validateBtn.style.display = 'none'; + }, 150); + }); + + // --- Validate button action --- + validateBtn.onclick = () => { + if (input.value.trim()) { + addTag(input.value.trim()); + input.value = ''; + updateSuggestions(); + validateBtn.style.display = 'none'; + } + }; + + input.focus(); + updateSuggestions(); +} + +// --- Update tags in galleryImages array --- +function updateTags(index, tags) { + galleryImages[index].tags = tags; + saveGallery(); +} + +// --- Render hero images with delete buttons --- +function renderHero() { + const container = document.getElementById('hero'); + container.innerHTML = ''; + heroImages.forEach((img, i) => { + const div = document.createElement('div'); + div.className = 'photo flex-item flex-column'; + div.innerHTML = ` +
+ +
+
+
+ +
+
+ `; + container.appendChild(div); + }); + + // Show/hide Remove All button + const removeAllBtn = document.getElementById('remove-all-hero'); + if (removeAllBtn) { + removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none'; + } +} + +// --- Save gallery to server --- +async function saveGallery() { + await fetch('/api/gallery/update', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(galleryImages) + }); +} + +// --- Save hero to server --- +async function saveHero() { + await fetch('/api/hero/update', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(heroImages) + }); +} + +// --- Save all changes --- +async function saveChanges() { + await saveGallery(); + await saveHero(); + showToast('✅ Changes saved!', "success"); +} + +// --- Refresh gallery from folder --- +async function refreshGallery() { + await fetch('/api/gallery/refresh', { method: 'POST' }); + await loadData(); + showToast('🔄 Gallery updated from photos/gallery folder', "success"); +} + +// --- Refresh hero from folder --- +async function refreshHero() { + await fetch('/api/hero/refresh', { method: 'POST' }); + await loadData(); + showToast('🔄 Hero updated from photos/hero folder', "success"); +} + +// --- Show toast notification --- +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"); + toast.addEventListener("transitionend", () => toast.remove()); + }, duration); +} + +let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null } + +// --- Show delete confirmation modal --- +function showDeleteModal(type, index = null) { + pendingDelete = { type, index }; + const modalText = document.getElementById('delete-modal-text'); + if (type === 'gallery-all') { + modalText.textContent = "Are you sure you want to delete ALL gallery images?"; + } else if (type === 'hero-all') { + modalText.textContent = "Are you sure you want to delete ALL hero images?"; + } else { + modalText.textContent = "Are you sure you want to delete this image?"; + } + document.getElementById('delete-modal').style.display = 'flex'; +} + +// --- Hide modal --- +function hideDeleteModal() { + document.getElementById('delete-modal').style.display = 'none'; + pendingDelete = null; +} + +// --- Confirm deletion --- +async function confirmDelete() { + if (!pendingDelete) return; + if (pendingDelete.type === 'gallery') { + await actuallyDeleteGalleryImage(pendingDelete.index); + } else if (pendingDelete.type === 'hero') { + await actuallyDeleteHeroImage(pendingDelete.index); + } else if (pendingDelete.type === 'gallery-all') { + await actuallyDeleteAllGalleryImages(); + } else if (pendingDelete.type === 'hero-all') { + await actuallyDeleteAllHeroImages(); + } + hideDeleteModal(); +} + +// --- Actual delete functions --- +async function actuallyDeleteGalleryImage(index) { + const img = galleryImages[index]; + try { + const res = await fetch('/api/gallery/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ src: img.src.split('/').pop() }) + }); + const data = await res.json(); + if (res.ok) { + galleryImages.splice(index, 1); + renderGallery(); + await saveGallery(); + showToast("✅ Gallery image deleted!", "success"); + } else showToast("Error: " + data.error, "error"); + } catch(err) { + console.error(err); + showToast("Server error!", "error"); + } +} + +async function actuallyDeleteHeroImage(index) { + const img = heroImages[index]; + try { + const res = await fetch('/api/hero/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ src: img.src.split('/').pop() }) + }); + const data = await res.json(); + if (res.ok) { + heroImages.splice(index, 1); + renderHero(); + await saveHero(); + showToast("✅ Hero image deleted!", "success"); + } else showToast("Error: " + data.error, "error"); + } catch(err) { + console.error(err); + showToast("Server error!", "error"); + } +} + +// --- Bulk delete functions --- +async function actuallyDeleteAllGalleryImages() { + try { + const res = await fetch('/api/gallery/delete_all', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + galleryImages = []; + renderGallery(); + await saveGallery(); + showToast("✅ All gallery images removed!", "success"); + } else showToast("Error: " + data.error, "error"); + } catch(err) { + console.error(err); + showToast("Server error!", "error"); + } +} + +async function actuallyDeleteAllHeroImages() { + try { + const res = await fetch('/api/hero/delete_all', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + heroImages = []; + renderHero(); + await saveHero(); + showToast("✅ All hero images removed!", "success"); + } else showToast("Error: " + data.error, "error"); + } catch(err) { + console.error(err); + showToast("Server error!", "error"); + } +} + +// --- Modal event listeners and bulk delete buttons --- +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('delete-modal-close').onclick = hideDeleteModal; + document.getElementById('delete-modal-cancel').onclick = hideDeleteModal; + document.getElementById('delete-modal-confirm').onclick = confirmDelete; + + // Bulk delete buttons + const removeAllGalleryBtn = document.getElementById('remove-all-gallery'); + const removeAllHeroBtn = document.getElementById('remove-all-hero'); + if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all'); + if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all'); +}); + +// --- Initialize --- +loadData(); \ No newline at end of file diff --git a/src/webui/js/site-info.js b/src/webui/js/site-info.js new file mode 100644 index 0000000..3af7338 --- /dev/null +++ b/src/webui/js/site-info.js @@ -0,0 +1,456 @@ +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"); + toast.addEventListener("transitionend", () => toast.remove()); + }, duration); +} + +// --- Loader helpers --- +function showLoader(text = "Uploading...") { + const loader = document.getElementById("global-loader"); + if (loader) { + loader.classList.add("active"); + document.getElementById("loader-text").textContent = text; + } +} +function hideLoader() { + const loader = document.getElementById("global-loader"); + if (loader) loader.classList.remove("active"); +} + +document.addEventListener("DOMContentLoaded", () => { + // Form and menu logic + const form = document.getElementById("site-info-form"); + const menuList = document.getElementById("menu-items-list"); + const addMenuBtn = document.getElementById("add-menu-item"); + + let menuItems = []; + + // Render menu items + function renderMenuItems() { + menuList.innerHTML = ""; + menuItems.forEach((item, idx) => { + const div = document.createElement("div"); + div.style.display = "flex"; + div.style.gap = "8px"; + div.style.marginBottom = "6px"; + div.innerHTML = ` + + + + `; + menuList.appendChild(div); + }); + } + + // Update menu items from inputs + function updateMenuItemsFromInputs() { + const inputs = menuList.querySelectorAll("input"); + const items = []; + for (let i = 0; i < inputs.length; i += 2) { + const label = inputs[i].value.trim(); + const href = inputs[i + 1].value.trim(); + if (label || href) items.push({ label, href }); + } + menuItems = items; + } + + // Intellectual property paragraphs logic + const ipList = document.getElementById("ip-list"); + const addIpBtn = document.getElementById("add-ip-paragraph"); + let ipParagraphs = []; + + // Render IP paragraphs + function renderIpParagraphs() { + ipList.innerHTML = ""; + ipParagraphs.forEach((item, idx) => { + const div = document.createElement("div"); + div.style.display = "flex"; + div.style.gap = "8px"; + div.style.marginBottom = "6px"; + div.innerHTML = ` + + + `; + ipList.appendChild(div); + }); + } + + // Update IP paragraphs from textareas + function updateIpParagraphsFromInputs() { + const textareas = ipList.querySelectorAll("textarea"); + ipParagraphs = Array.from(textareas).map(textarea => ({ + paragraph: textarea.value.trim() + })).filter(item => item.paragraph !== ""); + } + + // Build options + const convertImagesCheckbox = document.getElementById("convert-images-checkbox"); + const resizeImagesCheckbox = document.getElementById("resize-images-checkbox"); + + // Theme select + const themeSelect = document.getElementById("theme-select"); + + // Thumbnail upload and modal logic + const thumbnailInput = form?.elements["social.thumbnail"]; + const thumbnailUpload = document.getElementById("thumbnail-upload"); + const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn"); + const thumbnailPreview = document.getElementById("thumbnail-preview"); + const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn"); + + // Modal elements for delete confirmation + const deleteModal = document.getElementById("delete-modal"); + const deleteModalClose = document.getElementById("delete-modal-close"); + const deleteModalConfirm = document.getElementById("delete-modal-confirm"); + const deleteModalCancel = document.getElementById("delete-modal-cancel"); + + // Modal elements for theme deletion + const deleteThemeModal = document.getElementById("delete-theme-modal"); + const deleteThemeModalClose = document.getElementById("delete-theme-modal-close"); + const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm"); + const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel"); + const deleteThemeModalText = document.getElementById("delete-theme-modal-text"); + let themeToDelete = null; + + // Show/hide thumbnail preview, remove button, and choose button + function updateThumbnailPreview(src) { + if (thumbnailPreview) { + thumbnailPreview.src = src || ""; + thumbnailPreview.style.display = src ? "block" : "none"; + } + if (removeThumbnailBtn) { + removeThumbnailBtn.style.display = src ? "inline-block" : "none"; + } + if (chooseThumbnailBtn) { + chooseThumbnailBtn.style.display = src ? "none" : "inline-block"; + } + } + + // Choose thumbnail button triggers file input + if (chooseThumbnailBtn && thumbnailUpload) { + chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click()); + } + + // Handle thumbnail upload and refresh preview (with cache busting) + if (thumbnailUpload) { + thumbnailUpload.addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + showLoader("Uploading thumbnail..."); + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + if (thumbnailInput) thumbnailInput.value = result.filename; + updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`); + showToast("✅ Thumbnail uploaded!", "success"); + } else { + showToast("❌ Error uploading thumbnail", "error"); + } + }); + } + + // Remove thumbnail button triggers modal + if (removeThumbnailBtn) { + removeThumbnailBtn.addEventListener("click", () => { + deleteModal.style.display = "flex"; + }); + } + + // Modal logic for thumbnail deletion + if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) { + deleteModalClose.onclick = deleteModalCancel.onclick = () => { + deleteModal.style.display = "none"; + }; + window.onclick = function(event) { + if (event.target === deleteModal) { + deleteModal.style.display = "none"; + } + }; + deleteModalConfirm.onclick = async () => { + const res = await fetch("/api/thumbnail/remove", { method: "POST" }); + const result = await res.json(); + if (result.status === "ok") { + if (thumbnailInput) thumbnailInput.value = ""; + updateThumbnailPreview(""); + showToast("✅ Thumbnail removed!", "success"); + } else { + showToast("❌ Error removing thumbnail", "error"); + } + deleteModal.style.display = "none"; + }; + } + + // Theme upload logic (custom theme folder) + const themeUpload = document.getElementById("theme-upload"); + const chooseThemeBtn = document.getElementById("choose-theme-btn"); + if (chooseThemeBtn && themeUpload) { + chooseThemeBtn.addEventListener("click", () => themeUpload.click()); + themeUpload.addEventListener("change", async (e) => { + const files = Array.from(e.target.files); + if (files.length === 0) return; + showLoader("Uploading theme..."); + const formData = new FormData(); + files.forEach(file => { + formData.append("files", file, file.webkitRelativePath || file.name); + }); + const res = await fetch("/api/theme/upload", { method: "POST", body: formData }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + showToast("✅ Theme uploaded!", "success"); + // Refresh theme select after upload + fetch("/api/themes") + .then(res => res.json()) + .then(themes => { + themeSelect.innerHTML = ""; + themes.forEach(theme => { + const option = document.createElement("option"); + option.value = theme; + option.textContent = theme; + themeSelect.appendChild(option); + }); + }); + } else { + showToast("❌ Error uploading theme", "error"); + } + }); + } + + // Remove theme button triggers modal + const removeThemeBtn = document.getElementById("remove-theme-btn"); + if (removeThemeBtn && themeSelect) { + removeThemeBtn.addEventListener("click", () => { + const theme = themeSelect.value; + if (!theme) return showToast("❌ No theme selected", "error"); + if (["modern", "classic"].includes(theme)) { + showToast("❌ Cannot remove default theme", "error"); + return; + } + themeToDelete = theme; + deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`; + deleteThemeModal.style.display = "flex"; + }); + } + + // Modal logic for theme deletion + if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) { + deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => { + deleteThemeModal.style.display = "none"; + themeToDelete = null; + }; + window.onclick = function(event) { + if (event.target === deleteThemeModal) { + deleteThemeModal.style.display = "none"; + themeToDelete = null; + } + }; + deleteThemeModalConfirm.onclick = async () => { + if (!themeToDelete) return; + showLoader("Removing theme..."); + const res = await fetch("/api/theme/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme: themeToDelete }) + }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + showToast("✅ Theme removed!", "success"); + // Refresh theme select + fetch("/api/themes") + .then(res => res.json()) + .then(themes => { + themeSelect.innerHTML = ""; + themes.forEach(theme => { + const option = document.createElement("option"); + option.value = theme; + option.textContent = theme; + themeSelect.appendChild(option); + }); + }); + } else { + showToast(result.error || "❌ Error removing theme", "error"); + } + deleteThemeModal.style.display = "none"; + themeToDelete = null; + }; + } + + // Fetch theme list and populate select + if (themeSelect) { + fetch("/api/themes") + .then(res => res.json()) + .then(themes => { + themeSelect.innerHTML = ""; + themes.forEach(theme => { + const option = document.createElement("option"); + option.value = theme; + option.textContent = theme; + themeSelect.appendChild(option); + }); + // Set selected value after loading config + fetch("/api/site-info") + .then(res => res.json()) + .then(data => { + themeSelect.value = data.build?.theme || ""; + }); + }); + } + + // Load config from server and populate form + if (form) { + fetch("/api/site-info") + .then(res => res.json()) + .then(data => { + ipParagraphs = Array.isArray(data.legals?.intellectual_property) + ? data.legals.intellectual_property + : []; + renderIpParagraphs(); + menuItems = Array.isArray(data.menu?.items) ? data.menu.items : []; + renderMenuItems(); + form.elements["info.title"].value = data.info?.title || ""; + form.elements["info.subtitle"].value = data.info?.subtitle || ""; + form.elements["info.description"].value = data.info?.description || ""; + form.elements["info.canonical"].value = data.info?.canonical || ""; + form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || ""); + form.elements["info.author"].value = data.info?.author || ""; + form.elements["social.instagram_url"].value = data.social?.instagram_url || ""; + if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || ""; + updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : ""); + form.elements["footer.copyright"].value = data.footer?.copyright || ""; + form.elements["footer.legal_label"].value = data.footer?.legal_label || ""; + if (themeSelect) { + themeSelect.value = data.build?.theme || ""; + } + form.elements["legals.hoster_name"].value = data.legals?.hoster_name || ""; + form.elements["legals.hoster_address"].value = data.legals?.hoster_address || ""; + form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || ""; + // Build checkboxes + if (convertImagesCheckbox) { + convertImagesCheckbox.checked = !!data.build?.convert_images; + } + if (resizeImagesCheckbox) { + resizeImagesCheckbox.checked = !!data.build?.resize_images; + } + }); + } + + // Add menu item + if (addMenuBtn) { + addMenuBtn.addEventListener("click", () => { + menuItems.push({ label: "", href: "" }); + renderMenuItems(); + }); + } + + // Remove menu item + menuList.addEventListener("click", (e) => { + if (e.target.classList.contains("remove-menu-item")) { + const idx = parseInt(e.target.getAttribute("data-idx")); + menuItems.splice(idx, 1); + renderMenuItems(); + } + }); + + // Update menuItems on input change + menuList.addEventListener("input", () => { + updateMenuItemsFromInputs(); + }); + + // Add paragraph + if (addIpBtn) { + addIpBtn.addEventListener("click", () => { + ipParagraphs.push({ paragraph: "" }); + renderIpParagraphs(); + }); + } + + // Remove paragraph + ipList.addEventListener("click", (e) => { + if (e.target.classList.contains("remove-ip-paragraph")) { + const idx = parseInt(e.target.getAttribute("data-idx")); + ipParagraphs.splice(idx, 1); + renderIpParagraphs(); + } + }); + + // Update ipParagraphs on input change + ipList.addEventListener("input", () => { + updateIpParagraphsFromInputs(); + }); + + // Save config to server + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + updateMenuItemsFromInputs(); + updateIpParagraphsFromInputs(); + + // Check if thumbnail is set before saving (uploaded or present in input) + if (!thumbnailInput || !thumbnailInput.value) { + showLoader("Saving..."); + showToast("❌ Thumbnail is required.", "error"); + hideLoader(); + return; + } + + const build = { + theme: themeSelect ? themeSelect.value : "", + convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), + resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked) + }; + + const payload = { + info: { + title: form.elements["info.title"].value, + subtitle: form.elements["info.subtitle"].value, + description: form.elements["info.description"].value, + canonical: form.elements["info.canonical"].value, + keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean), + author: form.elements["info.author"].value + }, + social: { + instagram_url: form.elements["social.instagram_url"].value, + thumbnail: thumbnailInput ? thumbnailInput.value : "" + }, + menu: { + items: menuItems + }, + footer: { + copyright: form.elements["footer.copyright"].value, + legal_label: form.elements["footer.legal_label"].value + }, + build, + legals: { + hoster_name: form.elements["legals.hoster_name"].value, + hoster_address: form.elements["legals.hoster_address"].value, + hoster_contact: form.elements["legals.hoster_contact"].value, + intellectual_property: ipParagraphs + } + }; + // --- REMOVE loader for save --- + // showLoader("Saving..."); + const res = await fetch("/api/site-info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + const result = await res.json(); + if (result.status === "ok") { + showToast("✅ Site info saved!", "success"); + } else { + showToast("❌ Error saving site info", "error"); + } + }); + } +}); \ No newline at end of file diff --git a/src/webui/js/theme-editor.js b/src/webui/js/theme-editor.js new file mode 100644 index 0000000..f7b45c4 --- /dev/null +++ b/src/webui/js/theme-editor.js @@ -0,0 +1,467 @@ +async function fetchThemeInfo() { + const res = await fetch("/api/theme-info"); + return await res.json(); +} + +async function fetchLocalFonts(theme) { + const res = await fetch(`/api/local-fonts?theme=${encodeURIComponent(theme)}`); + return await res.json(); +} + +async function removeFont(theme, font) { + const res = await fetch("/api/font/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme, font }) + }); + return await res.json(); +} + +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); +} + +// --- Loader helpers --- +function showLoader(text = "Uploading...") { + const loader = document.getElementById("global-loader"); + if (loader) { + loader.classList.add("active"); + document.getElementById("loader-text").textContent = text; + } +} +function hideLoader() { + const loader = document.getElementById("global-loader"); + if (loader) loader.classList.remove("active"); +} + +// --- Color Picker +function setupColorPicker(colorId, btnId, textId, initial) { + const colorInput = document.getElementById(colorId); + const colorBtn = document.getElementById(btnId); + const textInput = document.getElementById(textId); + + colorInput.value = initial; + colorBtn.style.background = initial; + textInput.value = initial.toUpperCase(); + + colorInput.addEventListener("input", () => { + colorBtn.style.background = colorInput.value; + textInput.value = colorInput.value.toUpperCase(); + }); + + textInput.addEventListener("input", () => { + if (/^#[0-9A-F]{6}$/i.test(textInput.value)) { + colorInput.value = textInput.value; + colorBtn.style.background = textInput.value; + } + }); +} + +function setFontDropdown(selectId, value, options) { + const select = document.getElementById(selectId); + if (!select) return; + select.innerHTML = options.map(opt => + `` + ).join(""); +} + +function setFallbackDropdown(selectId, value) { + const select = document.getElementById(selectId); + if (!select) return; + select.value = (value === "serif" || value === "sans-serif") ? value : "sans-serif"; +} + +function setTextInput(inputId, value) { + const input = document.getElementById(inputId); + if (input) input.value = value; +} + +function renderGoogleFonts(googleFonts) { + const container = document.getElementById("google-fonts-fields"); + container.innerHTML = ""; + googleFonts.forEach((font, idx) => { + container.innerHTML += ` +
+ + + + + +
+ `; + }); +} + +function renderLocalFonts(fonts) { + const listDiv = document.getElementById("local-fonts-list"); + if (!listDiv) return; + listDiv.innerHTML = ""; + fonts.forEach(font => { + listDiv.innerHTML += ` +
+ ${font} + +
+ `; + }); +} + +document.addEventListener("DOMContentLoaded", async () => { + const themeInfo = await fetchThemeInfo(); + const themeNameSpan = document.getElementById("current-theme"); + if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name; + + const themeYaml = themeInfo.theme_yaml; + const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : []; + let localFonts = await fetchLocalFonts(themeInfo.theme_name); + + // Colors + if (themeYaml.colors) { + setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1"); + setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384"); + setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0"); + setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700"); + setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161"); + setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff"); + setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff"); + } + + // Fonts + function refreshFontDropdowns() { + setFontDropdown("font-primary", document.getElementById("font-primary").value, [ + ...googleFonts.map(f => f.family), + ...localFonts + ]); + setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [ + ...googleFonts.map(f => f.family), + ...localFonts + ]); + } + if (themeYaml.fonts) { + setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [ + ...googleFonts.map(f => f.family), + ...localFonts + ]); + setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif"); + setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [ + ...googleFonts.map(f => f.family), + ...localFonts + ]); + setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif"); + } + + // Font upload logic + const fontUploadInput = document.getElementById("font-upload"); + const chooseFontBtn = document.getElementById("choose-font-btn"); + const fontUploadStatus = document.getElementById("font-upload-status"); + const localFontsList = document.getElementById("local-fonts-list"); + + // Modal logic for font deletion + const deleteFontModal = document.getElementById("delete-font-modal"); + const deleteFontModalClose = document.getElementById("delete-font-modal-close"); + const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm"); + const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel"); + let fontToDelete = null; + + function refreshLocalFonts() { + renderLocalFonts(localFonts); + refreshFontDropdowns(); + } + + if (chooseFontBtn && fontUploadInput) { + chooseFontBtn.addEventListener("click", () => fontUploadInput.click()); + } + + if (fontUploadInput) { + fontUploadInput.addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + const ext = file.name.split('.').pop().toLowerCase(); + if (!["woff", "woff2"].includes(ext)) { + showToast("Only .woff and .woff2 fonts are allowed.", "error"); + return; + } + showLoader("Uploading font..."); + const formData = new FormData(); + formData.append("file", file); + formData.append("theme", themeInfo.theme_name); + const res = await fetch("/api/font/upload", { method: "POST", body: formData }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + showToast("✅ Font uploaded!", "success"); + localFonts = await fetchLocalFonts(themeInfo.theme_name); + refreshLocalFonts(); + } else { + showToast("Error uploading font.", "error"); + } + }); + } + + // Remove font button triggers modal + if (localFontsList) { + localFontsList.addEventListener("click", (e) => { + if (e.target.classList.contains("remove-font-btn")) { + fontToDelete = e.target.dataset.font; + document.getElementById("delete-font-modal-text").textContent = + `Are you sure you want to remove the font "${fontToDelete}"?`; + deleteFontModal.style.display = "flex"; + } + }); + } + + // Modal logic for font deletion + if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) { + deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => { + deleteFontModal.style.display = "none"; + fontToDelete = null; + }; + window.onclick = function(event) { + if (event.target === deleteFontModal) { + deleteFontModal.style.display = "none"; + fontToDelete = null; + } + }; + deleteFontModalConfirm.onclick = async () => { + if (!fontToDelete) return; + showLoader("Removing font..."); + const result = await removeFont(themeInfo.theme_name, fontToDelete); + hideLoader(); + if (result.status === "ok") { + showToast("Font removed!", "success"); + localFonts = await fetchLocalFonts(themeInfo.theme_name); + refreshLocalFonts(); + } else { + showToast("Error removing font.", "error"); + } + deleteFontModal.style.display = "none"; + fontToDelete = null; + }; + } + + // Initial render of local fonts + refreshLocalFonts(); + + // Favicon logic + const faviconInput = document.getElementById("favicon-path"); + const faviconUpload = document.getElementById("favicon-upload"); + const chooseFaviconBtn = document.getElementById("choose-favicon-btn"); + const faviconPreview = document.getElementById("favicon-preview"); + const removeFaviconBtn = document.getElementById("remove-favicon-btn"); + const deleteFaviconModal = document.getElementById("delete-favicon-modal"); + const deleteFaviconModalClose = document.getElementById("delete-favicon-modal-close"); + const deleteFaviconModalConfirm = document.getElementById("delete-favicon-modal-confirm"); + const deleteFaviconModalCancel = document.getElementById("delete-favicon-modal-cancel"); + + function updateFaviconPreview(src) { + if (faviconPreview) { + faviconPreview.src = src || ""; + faviconPreview.style.display = src ? "block" : "none"; + } + if (removeFaviconBtn) { + removeFaviconBtn.style.display = src ? "block" : "none"; + } + if (chooseFaviconBtn) { + chooseFaviconBtn.style.display = src ? "none" : "block"; + } + } + + if (chooseFaviconBtn && faviconUpload) { + chooseFaviconBtn.addEventListener("click", () => faviconUpload.click()); + } + + if (faviconUpload) { + faviconUpload.addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + const ext = file.name.split('.').pop().toLowerCase(); + if (!["png", "jpg", "jpeg", "ico"].includes(ext)) { + showToast("Invalid file type for favicon.", "error"); + return; + } + showLoader("Uploading favicon..."); + const formData = new FormData(); + formData.append("file", file); + formData.append("theme", themeInfo.theme_name); + const res = await fetch("/api/favicon/upload", { method: "POST", body: formData }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + faviconInput.value = result.filename; + updateFaviconPreview(`/themes/${themeInfo.theme_name}/${result.filename}?t=${Date.now()}`); + showToast("✅ Favicon uploaded!", "success"); + } else { + showToast("Error uploading favicon", "error"); + } + }); + } + + if (removeFaviconBtn) { + removeFaviconBtn.addEventListener("click", () => { + deleteFaviconModal.style.display = "flex"; + }); + } + + if (deleteFaviconModal && deleteFaviconModalClose && deleteFaviconModalConfirm && deleteFaviconModalCancel) { + deleteFaviconModalClose.onclick = deleteFaviconModalCancel.onclick = () => { + deleteFaviconModal.style.display = "none"; + }; + window.onclick = function(event) { + if (event.target === deleteFaviconModal) { + deleteFaviconModal.style.display = "none"; + } + }; + deleteFaviconModalConfirm.onclick = async () => { + showLoader("Removing favicon..."); + const res = await fetch("/api/favicon/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme: themeInfo.theme_name }) + }); + const result = await res.json(); + hideLoader(); + if (result.status === "ok") { + faviconInput.value = ""; + updateFaviconPreview(""); + showToast("✅ Favicon removed!", "success"); + } else { + showToast("Error removing favicon", "error"); + } + deleteFaviconModal.style.display = "none"; + }; + } + + if (themeYaml.favicon && themeYaml.favicon.path) { + faviconInput.value = themeYaml.favicon.path; + updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`); + } else { + updateFaviconPreview(""); + } + + // Google Fonts + renderGoogleFonts(googleFonts); + + // Add Google Font + const addGoogleFontBtn = document.getElementById("add-google-font"); + if (addGoogleFontBtn) { + addGoogleFontBtn.addEventListener("click", async () => { + googleFonts.push({ family: "", weights: [] }); + await fetch("/api/theme-google-fonts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts }) + }); + const updatedThemeInfo = await fetchThemeInfo(); + const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || []; + googleFonts.length = 0; + googleFonts.push(...updatedGoogleFonts); + renderGoogleFonts(googleFonts); + refreshFontDropdowns(); + }); + } + + const googleFontsFields = document.getElementById("google-fonts-fields"); + if (googleFontsFields) { + googleFontsFields.addEventListener("blur", async (e) => { + if ( + e.target.name && + (e.target.name.endsWith("[family]") || e.target.name.endsWith("[weights]")) + ) { + const fontFields = googleFontsFields.querySelectorAll(".input-field"); + googleFonts.length = 0; + fontFields.forEach(field => { + const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim(); + const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value + .split(",").map(w => w.trim()).filter(Boolean); + googleFonts.push({ family, weights }); + }); + await fetch("/api/theme-google-fonts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts }) + }); + const updatedThemeInfo = await fetchThemeInfo(); + const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || []; + googleFonts.length = 0; + googleFonts.push(...updatedGoogleFonts); + renderGoogleFonts(googleFonts); + refreshFontDropdowns(); + } + }, true); + + googleFontsFields.addEventListener("click", async (e) => { + if (e.target.classList.contains("remove-google-font")) { + const idx = Number(e.target.dataset.idx); + googleFonts.splice(idx, 1); + await fetch("/api/theme-google-fonts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts }) + }); + const updatedThemeInfo = await fetchThemeInfo(); + const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || []; + googleFonts.length = 0; + googleFonts.push(...updatedGoogleFonts); + renderGoogleFonts(googleFonts); + refreshFontDropdowns(); + } + }); + } + + document.getElementById("theme-editor-form").addEventListener("submit", async (e) => { + e.preventDefault(); + showLoader("Saving theme..."); + const data = {}; + data.colors = { + primary: document.getElementById("color-primary-text").value, + primary_dark: document.getElementById("color-primary-dark-text").value, + secondary: document.getElementById("color-secondary-text").value, + accent: document.getElementById("color-accent-text").value, + text_dark: document.getElementById("color-text-dark-text").value, + background: document.getElementById("color-background-text").value, + browser_color: document.getElementById("color-browser-color-text").value + }; + data.fonts = { + primary: { + name: document.getElementById("font-primary").value, + fallback: document.getElementById("font-primary-fallback").value + }, + secondary: { + name: document.getElementById("font-secondary").value, + fallback: document.getElementById("font-secondary-fallback").value + } + }; + data.favicon = { + path: faviconInput.value + }; + data.google_fonts = []; + document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => { + const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value; + const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value + .split(",").map(w => w.trim()).filter(w => w); + if (family) data.google_fonts.push({ family, weights }); + }); + + const res = await fetch("/api/theme-info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data }) + }); + hideLoader(); + if (res.ok) { + showToast("✅ Theme saved!", "success"); + } else { + showToast("Error saving theme.", "error"); + } + }); +}); \ No newline at end of file diff --git a/src/webui/js/upload.js b/src/webui/js/upload.js new file mode 100644 index 0000000..d2a45f1 --- /dev/null +++ b/src/webui/js/upload.js @@ -0,0 +1,64 @@ +// --- Loader helpers --- +function showLoader(text = "Uploading...") { + const loader = document.getElementById("global-loader"); + if (loader) { + loader.classList.add("active"); + document.getElementById("loader-text").textContent = text; + } +} +function hideLoader() { + const loader = document.getElementById("global-loader"); + if (loader) loader.classList.remove("active"); +} + +// --- Upload gallery images --- +const galleryInput = document.getElementById('upload-gallery'); +if (galleryInput) { + galleryInput.addEventListener('change', async (e) => { + const files = e.target.files; + if (!files.length) return; + showLoader("Uploading photos..."); + const formData = new FormData(); + for (const file of files) formData.append('files', file); + + try { + const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData }); + const data = await res.json(); + hideLoader(); + if (res.ok) { + showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success"); + if (typeof refreshGallery === "function") refreshGallery(); + } else showToast('Error: ' + data.error, "error"); + } catch(err) { + hideLoader(); + console.error(err); + showToast('Server error!', "error"); + } finally { e.target.value = ''; } + }); +} + +// --- Upload hero images --- +const heroInput = document.getElementById('upload-hero'); +if (heroInput) { + heroInput.addEventListener('change', async (e) => { + const files = e.target.files; + if (!files.length) return; + showLoader("Uploading hero photos..."); + const formData = new FormData(); + for (const file of files) formData.append('files', file); + + try { + const res = await fetch('/api/hero/upload', { method: 'POST', body: formData }); + const data = await res.json(); + hideLoader(); + if (res.ok) { + showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success"); + if (typeof refreshHero === "function") refreshHero(); + } else showToast('Error: ' + data.error, "error"); + } catch(err) { + hideLoader(); + console.error(err); + showToast('Server error!', "error"); + } finally { e.target.value = ''; } + }); +} \ No newline at end of file diff --git a/src/webui/site-info/index.html b/src/webui/site-info/index.html new file mode 100644 index 0000000..564e31b --- /dev/null +++ b/src/webui/site-info/index.html @@ -0,0 +1,182 @@ +{% extends "template/base.html" %} + +{% block title %}Lumeex - Site Info{% endblock %} + +{% block content %} + +

Edit Site Info

+
+ +
+

Info

+

Set the basic information for your site and SEO

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Social

+

Set your social media links and thumbnail for link sharing

+
+
+ + + + + +
+ + + +
+
+
+
+ +
+

Menu

+

Manage your site menu items. You can use tag combination to propose custom filters

+
+
+ + +
+
+
+ +
+

Footer

+

Set your copyright informations and legal link name

+
+
+ + +
+
+ + +
+
+
+ +
+

Legals

+

Set your legal informations

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+

Build

+

Select a theme from the dropdown menu or add your custom theme folder

+
+
+ + + + + + +

If checked, images will be converted for web and resized to fit the theme

+ + +
+
+
+ +
+
+

Steps

+

Follow the steps to generate your static gallery

+ +
+ + +
+ +
+ +
+ +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/webui/style/style.css b/src/webui/style/style.css new file mode 100644 index 0000000..c75c539 --- /dev/null +++ b/src/webui/style/style.css @@ -0,0 +1,1039 @@ +/* --- Base Styles --- */ +body { + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + background: #111010; + color: #FBFBFB; + display: flex; + flex-direction: column; + min-height: 100vh; + margin:0px; + width: 100vw; +} + +a { + text-decoration: none; + color: #d3d3d3; +} + +h1, h2 { + color: #FBFBFB; +} +h2 { + color: #55c3ec; +} + +.content-inner { + margin: 0 auto; + max-width: 1220px; + padding-top: 70px; + width: 100%; +} +.inner { + padding: 0 40px; + margin: auto; + width: 100%; + box-sizing: border-box; +} + + +/* --- Navbar & Burger Menu --- */ + +.nav-bar { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 70px; + background: #0c0d0c29; + z-index: 1000; + backdrop-filter: blur(20px); + border-bottom: 1px solid #21212157; + display: flex; + align-items: center; +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1140px; + width: 100%; + padding: 0 40px; + margin: 0 auto; + height: 70px; + position: relative; +} + +.nav-header { + display: flex; + align-items: center; +} + +.nav-title { + display: flex; + align-items: center; + font-size: 22px; + color: #fff; +} + +.nav img { + height: 30px; +} + +.nav-links { + display: flex; + align-items: center; +} + +.nav-list { + display: flex; + align-items: center; + gap: 0; + list-style: none; + margin: 0; + padding: 0; +} + +.nav-item { + display: flex; + align-items: center; +} + +.nav-item a { + display: flex; + align-items: center; + font-weight: bold; + color:#fff; + transition: all 0.2s ease; +} + +.nav-item a:hover { + display: flex; + align-items: center; + font-weight: bold; + color: #55c3ec; + transition: all 0.2s ease; +} + +.nav-list > li + li::before { + content: " → "; + color: #ffc700; + margin: 0 8px; +} + +.button { + padding: 10px 25px; + border-radius: 40px; + margin-left: 10px; + font-size: 14px; + background: linear-gradient(135deg, #26c4ff, #016074); + color: #fff; + font-weight: 700; + border: none; + cursor: pointer; + transition: background 0.2s; +} + +.button:hover { + background: linear-gradient(135deg, #72d9ff, #26657e); +} + +/* --- Burger Menu --- */ +.nav-burger { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + cursor: pointer; + margin-left: auto; + z-index: 1100; +} + +.nav-burger span { + display: block; + width: 28px; + height: 4px; + margin: 4px; + background: #fff; + border-radius: 2px; + transition: 0.3s; + position: relative; +} + +/* --- Responsive Navbar & Burger --- */ +@media (max-width: 768px) { + .nav-burger { + display: flex; + } + + .nav-list > li::before { + display: none; + } + + .nav-links { + position: absolute; + top: 70px; + left: 0; + width: 100vw; + flex-direction: column; + align-items: flex-start; + padding: 24px 0 12px 0; + display: none; + z-index: 1099; + backdrop-filter: blur(20px); + background-color: #000000d4 + } + .nav-links .nav-list { + flex-direction: column; + width: 100%; + } + .nav-links .nav-item { + width: 100%; + margin: 0; + padding: 0; + } + .nav-links .nav-item a, + .nav-links .nav-item button { + width: 100%; + padding: 16px 24px; + text-align: center; + font-size: 18px; + border: none; + justify-content: center; + margin: 0 20px; + } + + /* Show menu when burger is checked */ + .nav-toggle:checked ~ .nav-burger + .nav-links { + display: flex; + } + /* Animate burger to X */ + .nav-toggle:checked ~ .nav-burger span:nth-child(1) { + transform: translateY(12px) rotate(45deg); + } + .nav-toggle:checked ~ .nav-burger span:nth-child(2) { + opacity: 0; + } + .nav-toggle:checked ~ .nav-burger span:nth-child(3) { + transform: translateY(-12px) rotate(-45deg); + } +} +/* --- Upload Section --- */ +.section { + margin-bottom: 30px; + background-color: rgb(67 67 67 / 26%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 1px solid #2f2e2e80; + border-radius: 8px; + padding: 0px 20px 20px 20px; + width: 100%; + box-sizing: border-box; +} + +.section label { + cursor: pointer; +} + +/* --- Gallery & Hero Grid --- */ +#gallery, #hero { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 15px; + margin-top: 30px; +} + +/* --- Photo Card --- */ +.photo { + background-color: rgb(67 67 67 / 26%); + border-radius: 6px; + padding: 10px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 1px solid #2f2e2e80; +} + +.photo img { + max-width: 100%; + border-radius: 4px; + margin-bottom: 8px; +} + +.photo input[type="text"] { + width: 100%; + padding: 4px 6px; + border-radius: 30px; + color: rgb(221, 221, 221); +} + +.photo button { + padding: 4px; + border: none; + background-color:rgb(121 26 19); + color: white; + border-radius: 30px; + cursor: pointer; + transition: background-color 0.2s; + margin: 5px 4px 0 4px; + width:100%; +} + +.photo button:hover { + background-color: #d32f2f; +} + + +/* --- Toast Notifications --- */ +#toast-container { + position: fixed; + bottom: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 9999; +} + +.toast { + background: rgba(0,0,0,0.85); + color: white; + padding: 0.75rem 1.25rem; + border-radius: 30px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + opacity: 0; + transform: translateY(20px); + transition: opacity 0.5s ease, transform 0.5s ease; + pointer-events: none; + backdrop-filter: blur(20px); +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.toast.success { background-color: #28a7468c; } +.toast.error { background-color: #dc3545; } + +/* --- Tags --- */ +.tag-input { + display: flex; + align-content: flex-start; + gap: 4px; + padding: 4px; + position: relative; + z-index: 1; + margin-top: 10px; +} + +.tag-input input { + border: none; + outline: none; + min-width: 60px; + background-color: #1f2223; + border: 1px solid #585858; + margin-top: 5px; +} + +.tag { + background-color: #074053; + padding: 0.2em 0.5em; + border-radius: 15px; + display: flex; + align-items: center; + font-size: 14px; +} + +.tag .remove-tag { + margin-left: 4px; + cursor: pointer; + font-weight: bold; +} + +.tag-input ul.suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #181a1b; + border-top: none; + list-style: none; + margin: 0; + padding: 0; + max-height: 150px; + overflow-y: auto; + z-index: 999; + display: none; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.tag-input ul.suggestions li { + padding: 6px 8px; + cursor: pointer; +} + +.tag-input ul.suggestions li:hover { + background-color: #007782; +} + +.tags-display { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.photo button.validate-tag-btn { + border-radius: 30px; + border: none; + background: #049b3d; + color: #fff; + font-size: 10px; + cursor: pointer; + margin-left: 4px; + transition: all ease 0.2s; + width: 35px; + border: 1px solid #585858; +} + +.photo button.validate-tag-btn:hover { + background: #02cb4e; +} + + +.suggestions li.selected { + background-color: #007782; + color: white; + cursor: pointer; +} +.suggestions li { + cursor: pointer; +} + +/* --- Flex Utilities --- */ +.flex-item { + display: flex; +} + +.flex-column { + flex-direction: column; +} + +.flex-full { + flex: 1; + flex-direction: column-reverse; +} + +.flex-end { + align-items: flex-end; + width: 100% +} + +/* --- Upload Buttons --- */ +.up-btn, .footer-links a{ + display: inline-block; + background: #00000000; + color: #fff; + padding: 0.5em 1em; + border-radius: 30px; + cursor: pointer; + font-weight: bold; + text-align: center; + transition: all 0.1s ease; + user-select: none; + font-size: 14px; + border: 1px solid #585858; +} + +.up-btn:hover, .footer-links a:hover { + background: #2d2d2d; +} + +/* --- Modal Styles --- */ +.modal { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + margin: 0 30px; +} + +.modal-content { + background: #000000a3; + color: #fff; + padding: 2rem 2.5rem; + border-radius: 10px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); + min-width: 200px; + max-width: 90vw; + position: relative; + text-align: center; + backdrop-filter: blur(20px); +} + +.modal-close { + position: absolute; + top: 12px; right: 18px; + font-size: 1.5rem; + cursor: pointer; + color: #fff; + opacity: 0.7; +} +.modal-close:hover { opacity: 1; } + +.modal-actions { + margin-top: 2rem; + display: flex; + gap: 1rem; + justify-content: center; +} + +.modal-btn { + padding: 0.5em 1.5em; + border-radius: 30px; + border: none; + background: #09A0C1; + color: #fff; + font-weight: bold; + cursor: pointer; + font-size: 1rem; + transition: background 0.2s; +} + +.modal-btn.danger { + background: #c62828; +} + +.modal-btn:hover { + background: #55c3ec; +} + +.modal-btn.danger:hover { + background: #d32f2f; +} + +/* --- Upload Actions Row --- */ +.upload-actions-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 10px; +} + +/* --- Remove All Buttons --- */ +#remove-all-hero, #remove-all-gallery { + background: #2d2d2d; + color: white; + display: none; + margin-bottom: 6px; +} + +#remove-all-gallery:hover, +#remove-all-hero:hover { + background: rgb(121, 26, 19); +} + +@media (max-width: 500px) { + .upload-actions-row { + flex-direction: column; + align-items: stretch; + gap: 8px; + } +} + +/* --- Forms --- */ + +fieldset { + background-color: rgb(67 67 67 / 26%); + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 1px solid #2f2e2e80; + margin-bottom: 28px; + padding: 0 28px 32px 28px; + margin: 32px auto; +} + +legend { + font-size: 1.2em; + font-weight: 700; + color: #26c4ff; + margin-bottom: 12px; + letter-spacing: 1px; +} + +.fields { + display: flex; + flex-wrap: wrap; + gap: 18px; +} + +.input-field { + flex: 1 1 calc(33.333% - 18px); + min-width: 150px; + max-width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +label { + font-size: 13px; + font-weight: 600; + color: #e3e3e3; + margin-bottom: 6px; + letter-spacing: 0.5px; +} + +#site-info-form input, #theme-editor-form input, +#site-info-form textarea, #theme-editor-form textarea, +#site-info-form select, #theme-editor-form select { + background: #1f2223; + color: #fff; + border: 1px solid #585858; + border-radius: 8px; + font-size: 15px; + font-weight: 400; + padding: 10px 14px; + margin-bottom: 4px; + outline: none; + transition: border-color 0.2s, background 0.2s; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); +} + +#theme-editor-form input, #theme-editor-form textarea,#theme-editor-form select { + margin-bottom: 18px; +} + +#theme-editor-form .fields { + gap: 0 18px; +} + + + +#site-info-form input::placeholder, +#theme-editor-form input::placeholder, +#site-info-form textarea::placeholder, +#theme-editor-form textarea::placeholder { + color: #585858; + font-style: italic; +} + +#site-info-form input:focus, +#theme-editor-form input:focus, +#site-info-form textarea:focus, +#theme-editor-form textarea:focus, +#site-info-form select:focus, +#theme-editor-form select:focus { + border-color: #585858; + background: #161616; +} +#site-info-form textarea, +#theme-editor-form textarea { + min-height: 60px; + resize: vertical; +} + +#input[type="file"] { + background: none; + color: #fff; + border: none; + padding: 0; + margin-top: 2px; +} + +img#thumbnail-preview { + margin-top: 8px; + border-radius: 8px; + border: 1px solid #585858; +} + +#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] { + background: linear-gradient(135deg, #26c4ff, #016074); + color: #fff; + font-weight: 700; + border: none; + border-radius: 30px; + padding: 12px 32px; + font-size: 1.1em; + margin: 0 0 45px 0; + cursor: pointer; + box-shadow: 0 4px 16px rgba(38,196,255,0.15); + transition: background 0.2s; +} + +#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover { + background: linear-gradient(135deg, #72d9ff, #26657e); +} + +#site-info-form button[type="button"], #theme-editor-form button[type="button"] { + background: #00000000; + color: #fff; + border: none; + border-radius: 30px; + padding: 7px 18px; + font-size: 0.98em; + margin-top: 8px; + cursor: pointer; + transition: background 0.2s; + border: 1px solid #585858; +} + +#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover { + background: #2d2d2d; + color: #fff; +} + + +#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph { + margin-top: 0px; + margin-bottom: 4px; + border-radius: 30px; + background: #2d2d2d; +} + +#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover { + background: rgb(121, 26, 19); +} + +#site-info-form button.remove-btn, #theme-editor-form button.remove-btn { + + border-radius: 30px; + background: #2d2d2d; +} + +#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover { + background: rgb(121, 26, 19); +} + +#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label { + margin-top: 10px; +} + +#favicon-preview { + margin-top: 8px; +} + + +.fields.color-fields { + gap: 8px; + position: relative; + flex-wrap: nowrap; +} + + +#theme-editor-form button.color-btn { + height: 40px; + border-radius: 40px; + cursor: pointer; + display: inline-block; + margin-top: 0px; + margin-bottom: 4px; + width: 40px; +} + +input[type="color"].color-input { + margin:0; + padding: 0; + cursor: pointer; + height:100%; + position:absolute; + left:0; + top:0; + opacity:0; +} + +fieldset p, .section p { + font-size: 14px; + font-style: italic; + color: #b3b3b3; + margin-top: 0px; +} + +.font-name { + background: #1f2223; + color: #b3b3b3; + border: 1px solid #585858; + border-radius: 8px; + font-size: 15px; + font-weight: 400; + padding: 10px 14px; + margin-bottom: 4px; + outline: none; + transition: border-color 0.2s, background 0.2s; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); +} + +#theme-editor-form button[type="button"]#choose-font-btn { + margin-top: 16px; +} + +/* --- Stepper --- */ + +#stepper { + display: flex; + gap: 18px; + flex-wrap: nowrap; + align-items: stretch; + padding: 0; + margin-left: auto; + margin-right: auto; +} + +#stepper li, +#stepper > div { + display: flex; + align-items: center; + +} + + #stepper > div::before { + content: "→"; + color: #ffc700; + } + +#stepper li a, #stepper li button, #stepper > div { +justify-content: center; + min-width: 100px; + border: 1px solid #585858; + padding: 16px; + border-radius: 8px; + background: #111010; + text-align: center; + box-sizing: border-box; + width: 100%; +} + +#stepper li a, #stepper li button { + height: 100%; + align-items: center; + display: flex; + font-weight: bold; +} + +#stepper li button#stepper-build { + background: linear-gradient(135deg, #26c4ff, #016074); + transition: all 0.2s ease; + font-weight: bold; + color:#fff; + font-size: 16px; +} + +#stepper li button#stepper-build:hover { + background: linear-gradient(135deg, #72d9ff, #26657e); + transition: all 0.2s ease; + color:#fff; + cursor: pointer; +} + +#stepper li a:hover, #stepper li a.step-active { + color: #fff; + transition: all 0.2s ease; + background: #277fa0; + font-weight: bold; +} + +#stepper li button#stepper-build::before { + content: "🚀 "; + margin-right: 8px; +} + +#stepper li a { + text-decoration: none; + color:#d3d3d3 +} + +#stepper li { + flex: 1 1 auto; +} + +#stepper > div { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + color: #ffc700; + border: none; + background: none; + padding: 0; + min-width: 0; + width: auto; +} + +.stepper { + width: 100%; +} + +/* --- Footer --- */ + +#footer { + background-color: #0c0d0c29; + z-index: 1000; + backdrop-filter: blur(20px); + border-top: 1px solid #21212157; + width: 100%; + margin-top: auto; +} + +.footer-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; +} + + +.footer-links, .footer-links a { + display: flex; + gap: 12px; +} + +.footer-links a { + gap: 8px; +} + +.lum-first { + font-weight: bold; +} +.lum-second { + color: #55c3ec; + font-weight: bold; +} + +#footer a { + color: #fff; +} + +.footer-credit .lum-first::before { + content: url(/img/favicon.svg); + display: inline-block; +} + +#footer .content-inner { + padding-top: 0px; +} + +.icon { + width: 16px; + height: 16px; + display: flex; +} + +.icon-text { + display: flex; +} + +/* --- Global Loader & Spinner --- */ +#global-loader { + display: none; + position: fixed; + top: 0; left: 0; width: 100vw; height: 100vh; + z-index: 99999; + background: rgba(0,0,0,0.4); + align-items: center; + justify-content: center; +} + +#global-loader.active { + display: flex; +} + +.loader-inner { + opacity: 0; + transition: opacity 0.9s cubic-bezier(.4,0,.2,1); + background: #0c0d0c29; + padding: 32px 48px; + border-radius: 16px; + box-shadow: 0 2px 24px #000; + display: flex; + flex-direction: column; + align-items: center; + backdrop-filter: blur(20px); +} + +#global-loader.active .loader-inner { + opacity: 1; +} + +.loader-spinner { + width: 48px; + height: 48px; + border: 6px solid #55c3ec; + border-top: 6px solid #222; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +#loader-text { + margin-top: 18px; + color: #fff; + font-size: 18px; +} + +@keyframes spin { 100% { transform: rotate(360deg); } } + + +/* --- Responsive Adjustments --- */ +@media (max-width: 768px) { + + .nav { + padding: 0 20px; + } + .inner { + padding: 0 20px; + } + .section label { + display: block; + margin-bottom: 10px; + } + + #menu-items-list > div { + flex-direction: column; + } + + #stepper { + flex-direction: column; + } + + #stepper li { + width: 100%; + } + + .footer-container, .footer-links { + flex-direction: column; + } + + #ip-list > div { + flex-direction: column; + } + #stepper > div { + flex-direction: column; + } + #stepper > div::before { + content: "↓"; + color: #ffc700; + } + #stepper > div { + /* Hide the default arrow */ + color: transparent; + } + #site-info-form, #theme-editor-form { + padding: 18px 8px; + } + .input-field { + min-width: 100%; + margin-bottom: 12px; + } + + #color-picker .input-field{ + min-width: 170px; + } + #theme-editor-form .fields.color-fields { + gap: 0 8px; + position: relative; + flex-wrap: nowrap; + } +} \ No newline at end of file diff --git a/src/webui/template/base.html b/src/webui/template/base.html new file mode 100644 index 0000000..0aa7213 --- /dev/null +++ b/src/webui/template/base.html @@ -0,0 +1,86 @@ + + + + + + {% block title %}Lumeex{% endblock %} + + + + + + + +
+
+
+ + {% block content %}{% endblock %} + +
+ +
+
+
+ + + +
+
+
+
Uploading...
+
+
+ + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/src/webui/theme-editor/index.html b/src/webui/theme-editor/index.html new file mode 100644 index 0000000..2a97c8e --- /dev/null +++ b/src/webui/theme-editor/index.html @@ -0,0 +1,182 @@ +{% extends "template/base.html" %} + +{% block title %}Lumeex - Theme Editor{% endblock %} + +{% block content %} + +

Edit Theme

+ +
+ Current theme: +
+
+ +
+

Colors

+

Set the color values for your theme

+
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+
+ +
+

Google Fonts

+

Add Google Fonts to your theme

+
+ +
+ +
+ +
+

Upload Custom Font

+

Supported formats: .woff, .woff2

+ +
+ +
+ +
+

Fonts

+

Select where to apply your fonts

+
+
+ + + + +
+
+ + + + +
+
+
+ +
+

Favicon

+

Supported formats: .png, .jpg, .jpeg

+
+
+ + + + +
+ + +
+
+
+
+ +
+
+

Steps

+

Follow the steps to generate your static gallery

+ +
+ + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file