From 1b0b228273ad4b4bf8c527760ce433826d8b4b5b Mon Sep 17 00:00:00 2001 From: Djeex Date: Sat, 16 Aug 2025 10:29:51 +0200 Subject: [PATCH] Gallery front --- build.py | 2 +- gallery.py | 2 +- requirements.txt | 3 +- src/py/{ => builder}/__init__.py | 0 src/py/{ => builder}/css_generator.py | 3 + src/py/{ => builder}/gallery_builder.py | 5 + src/py/{ => builder}/html_generator.py | 5 + src/py/{ => builder}/image_processor.py | 0 src/py/{ => builder}/site_builder.py | 0 src/py/{ => builder}/utils.py | 13 +- src/py/webui/__init__.py | 0 src/py/webui/upload.py | 50 ++++++++ src/py/webui/webui.py | 112 ++++++++++++++++++ src/webui/index.html | 39 ++++++ src/webui/js/main.js | 151 ++++++++++++++++++++++++ src/webui/js/upload.js | 61 ++++++++++ src/webui/style/style.css | 98 +++++++++++++++ 17 files changed, 535 insertions(+), 9 deletions(-) rename src/py/{ => builder}/__init__.py (100%) rename src/py/{ => builder}/css_generator.py (95%) rename src/py/{ => builder}/gallery_builder.py (94%) rename src/py/{ => builder}/html_generator.py (94%) rename src/py/{ => builder}/image_processor.py (100%) rename src/py/{ => builder}/site_builder.py (100%) rename src/py/{ => builder}/utils.py (84%) create mode 100644 src/py/webui/__init__.py create mode 100644 src/py/webui/upload.py create mode 100644 src/py/webui/webui.py create mode 100644 src/webui/index.html create mode 100644 src/webui/js/main.js create mode 100644 src/webui/js/upload.js create mode 100644 src/webui/style/style.css 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/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/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 95% rename from src/py/css_generator.py rename to src/py/builder/css_generator.py index 82737ca..c550954 100644 --- a/src/py/css_generator.py +++ b/src/py/builder/css_generator.py @@ -3,6 +3,7 @@ from pathlib import Path from shutil import copyfile def generate_css_variables(colors_dict, output_path): + """Generate css variables for theme colors""" css_lines = [":root {"] for key, value in colors_dict.items(): css_lines.append(f" --color-{key.replace('_', '-')}: {value};") @@ -13,6 +14,7 @@ def generate_css_variables(colors_dict, output_path): logging.info(f"[✓] CSS variables written to {output_path}") def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): + """Generate css variables fonts""" font_files = list(fonts_dir.glob("*")) font_faces = {} preload_links = [] @@ -57,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): return preload_links def generate_google_fonts_link(fonts): + """Generate src link for Google fonts""" if not fonts: return "" families = [] diff --git a/src/py/gallery_builder.py b/src/py/builder/gallery_builder.py similarity index 94% rename from src/py/gallery_builder.py rename to src/py/builder/gallery_builder.py index eaff225..2666f8c 100644 --- a/src/py/gallery_builder.py +++ b/src/py/builder/gallery_builder.py @@ -10,6 +10,7 @@ GALLERY_DIR = Path("config/photos/gallery") HERO_DIR = Path("config/photos/hero") def load_yaml(path): + """Load gallery config .yaml file""" print(f"[→] Loading {path}...") if not os.path.exists(path): print(f"[✗] File not found: {path}") @@ -21,11 +22,13 @@ def load_yaml(path): return data def save_yaml(data, path): + """Save modified gallery config .yaml file""" with open(path, "w", encoding="utf-8") as f: yaml.dump(data, f, sort_keys=False, allow_unicode=True) print(f"[✓] Saved updated YAML to {path}") def get_all_image_paths(directory): + """Get the path to record for builded site""" return sorted([ str(p.relative_to(directory.parent)).replace("\\", "/") for p in directory.rglob("*") @@ -33,6 +36,7 @@ def get_all_image_paths(directory): ]) def update_gallery(): + """Update the gallery photo list""" print("\n=== Updating gallery.yaml (gallery section) ===") gallery = load_yaml(GALLERY_YAML) @@ -71,6 +75,7 @@ def update_gallery(): print("[✓] No changes to gallery.yaml (gallery)") def update_hero(): + """Update the hero photo list""" print("\n=== Updating gallery.yaml (hero section) ===") gallery = load_yaml(GALLERY_YAML) diff --git a/src/py/html_generator.py b/src/py/builder/html_generator.py similarity index 94% rename from src/py/html_generator.py rename to src/py/builder/html_generator.py index 2ac932e..9ee3872 100644 --- a/src/py/html_generator.py +++ b/src/py/builder/html_generator.py @@ -3,6 +3,7 @@ import logging from pathlib import Path def render_template(template_path, context): + """Render html templates""" with open(template_path, encoding="utf-8") as f: content = f.read() for key, value in context.items(): @@ -11,6 +12,7 @@ def render_template(template_path, context): return content def render_gallery_images(images): + """Render the photo gallery""" html = "" for img in images: tags = " ".join(img.get("tags", [])) @@ -24,6 +26,7 @@ def render_gallery_images(images): return html def generate_gallery_json_from_images(images, output_dir): + """Generte the hero carrousel photo list""" try: img_list = [img["src"] for img in images] output_path = output_dir / "data" / "gallery.json" @@ -35,6 +38,7 @@ def generate_gallery_json_from_images(images, output_dir): logging.error(f"[✗] Error generating gallery JSON: {e}") def generate_robots_txt(canonical_url, allowed_paths, output_dir): + """Generate the robot.txt""" robots_lines = ["User-agent: *"] # Block everything by default @@ -62,6 +66,7 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir): logging.error(f"[✗] Failed to write robots.txt: {e}") def generate_sitemap_xml(canonical_url, allowed_paths, output_dir): + """Generate the sitemap""" urlset_start = '\n\n' urlset_end = '\n' urls = "" 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 100% rename from src/py/site_builder.py rename to src/py/builder/site_builder.py diff --git a/src/py/utils.py b/src/py/builder/utils.py similarity index 84% rename from src/py/utils.py rename to src/py/builder/utils.py index abef78c..1e2e9c1 100644 --- a/src/py/utils.py +++ b/src/py/builder/utils.py @@ -4,6 +4,7 @@ from pathlib import Path from shutil import copytree, rmtree, copyfile def load_yaml(path): + """Load gallery and site .yaml conf""" if not path.exists(): logging.warning(f"[!] YAML file not found: {path}") return {} @@ -11,6 +12,7 @@ def load_yaml(path): return yaml.safe_load(f) def load_theme_config(theme_name, themes_dir): + """Load theme.yaml""" theme_dir = themes_dir / theme_name theme_config_path = theme_dir / "theme.yaml" if not theme_config_path.exists(): @@ -20,26 +22,25 @@ def load_theme_config(theme_name, themes_dir): return theme_vars, theme_dir def clear_dir(path: Path): + """Clear the output dir""" if not path.exists(): path.mkdir(parents=True) return - - # Remove all files and subdirectories inside path, but not path itself for child in path.iterdir(): if child.is_file() or child.is_symlink(): - child.unlink() # delete file or symlink + child.unlink() elif child.is_dir(): - rmtree(child) # delete directory and contents - -# Then replace your ensure_dir with this: + rmtree(child) def ensure_dir(path: Path): + """Create the output dir if it does not exist""" if not path.exists(): path.mkdir(parents=True) else: clear_dir(path) def copy_assets(js_dir, style_dir, build_dir): + """Copy public assets to output dir""" for folder in [js_dir, style_dir]: if folder.exists(): dest = build_dir / folder.name 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..fc2a800 --- /dev/null +++ b/src/py/webui/upload.py @@ -0,0 +1,50 @@ +import logging +from pathlib import Path +from flask import Blueprint, request, current_app +from werkzeug.utils import secure_filename + +# Create a Flask blueprint for upload routes +upload_bp = Blueprint("upload", __name__) + +# Allowed file extensions for uploads +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"} + +# Function to check if a file has an allowed extension +def allowed_file(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + +# Function to save uploaded file to a given folder +def save_uploaded_file(file, folder: Path): + folder.mkdir(parents=True, exist_ok=True) # Create folder if it doesn't exist + filename = secure_filename(file.filename) # Sanitize filename + file.save(folder / filename) # Save file to folder + logging.info(f"[✓] Uploaded {filename} to {folder}") + return filename # Return saved filename + +# Route to handle photo uploads for gallery or hero +@upload_bp.route("/api/
/upload", methods=["POST"]) +def upload_photo(section: str): + # Validate section + if section not in ["gallery", "hero"]: + return {"error": "Invalid section"}, 400 + + # Check if the request contains a file + if "file" not in request.files: + return {"error": "No file part"}, 400 + file = request.files["file"] + + # Check if a file was actually selected + if file.filename == "": + return {"error": "No selected file"}, 400 + + # Check file type and save it + if file and allowed_file(file.filename): + PHOTOS_DIR = current_app.config.get("PHOTOS_DIR") # Get base photos directory from config + if not PHOTOS_DIR: + return {"error": "Server misconfiguration"}, 500 + folder = PHOTOS_DIR / section # Target folder (gallery or hero) + filename = save_uploaded_file(file, folder) + return {"status": "ok", "filename": filename} + + # If file type is not allowed + return {"error": "File type not allowed"}, 400 diff --git a/src/py/webui/webui.py b/src/py/webui/webui.py new file mode 100644 index 0000000..952cc69 --- /dev/null +++ b/src/py/webui/webui.py @@ -0,0 +1,112 @@ +import logging +from pathlib import Path +from flask import Flask, jsonify, request, send_from_directory, render_template +from src.py.builder.gallery_builder import ( + GALLERY_YAML, GALLERY_DIR, HERO_DIR, + load_yaml, save_yaml, get_all_image_paths, update_gallery, update_hero +) +from src.py.webui.upload import upload_bp + +# --- Logging configuration --- +# Logs messages to console with INFO level +logging.basicConfig(level=logging.INFO, format="%(message)s") + +# --- Flask app setup --- +# WEBUI_PATH points to the web UI folder (templates + static) +WEBUI_PATH = Path(__file__).parents[2] / "webui" +app = Flask( + __name__, + template_folder=WEBUI_PATH, # where Flask looks for templates + static_folder=WEBUI_PATH, # where Flask serves static files + static_url_path="" # URL path prefix for static files +) + +# --- Absolute photos directory --- +# Used by upload.py and deletion endpoints +PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" +app.config["PHOTOS_DIR"] = PHOTOS_DIR + +# --- Register upload blueprint --- +# Handles /api/
/upload endpoints for gallery and hero images +app.register_blueprint(upload_bp) + +# --- Existing API routes --- + +# Serve main page +@app.route("/") +def index(): + return render_template("index.html") + +# Get gallery images (returns JSON array) +@app.route("/api/gallery", methods=["GET"]) +def get_gallery(): + data = load_yaml(GALLERY_YAML) + return jsonify(data.get("gallery", {}).get("images", [])) + +# Get hero images (returns JSON array) +@app.route("/api/hero", methods=["GET"]) +def get_hero(): + data = load_yaml(GALLERY_YAML) + return jsonify(data.get("hero", {}).get("images", [])) + +# Update gallery images with new JSON data +@app.route("/api/gallery/update", methods=["POST"]) +def update_gallery_api(): + images = request.json + data = load_yaml(GALLERY_YAML) + data["gallery"]["images"] = images + save_yaml(data, GALLERY_YAML) + return jsonify({"status": "ok"}) + +# Update hero images with new JSON data +@app.route("/api/hero/update", methods=["POST"]) +def update_hero_api(): + images = request.json + data = load_yaml(GALLERY_YAML) + data["hero"]["images"] = images + save_yaml(data, GALLERY_YAML) + return jsonify({"status": "ok"}) + +# Refresh gallery from the folder (rebuild YAML) +@app.route("/api/gallery/refresh", methods=["POST"]) +def refresh_gallery(): + update_gallery() + return jsonify({"status": "ok"}) + +# Refresh hero images from the folder +@app.route("/api/hero/refresh", methods=["POST"]) +def refresh_hero(): + update_hero() + return jsonify({"status": "ok"}) + +# Delete a gallery image file +@app.route("/api/gallery/delete", methods=["POST"]) +def delete_gallery_photo(): + data = request.json + src = data.get("src") # filename only + file_path = PHOTOS_DIR / "gallery" / src + if file_path.exists(): + file_path.unlink() # remove the file + return {"status": "ok"} + return {"error": "File not found"}, 404 + +# Delete a hero image file +@app.route("/api/hero/delete", methods=["POST"]) +def delete_hero_photo(): + data = request.json + src = data.get("src") # filename only + file_path = PHOTOS_DIR / "hero" / src + if file_path.exists(): + file_path.unlink() # remove the file + return {"status": "ok"} + return {"error": "File not found"}, 404 + +# Serve photos from /photos/
/ +@app.route("/photos/
/") +def photos(section, filename): + return send_from_directory(PHOTOS_DIR / section, filename) + +# --- Main entry point --- +if __name__ == "__main__": + logging.info("Starting WebUI at http://127.0.0.1:5000") + app.run(debug=True) diff --git a/src/webui/index.html b/src/webui/index.html new file mode 100644 index 0000000..cc82873 --- /dev/null +++ b/src/webui/index.html @@ -0,0 +1,39 @@ + + + + + Photo WebUI + + + +

Photo WebUI

+ +
+ + + +
+ + +
+

Gallery

+ + +
+ + +
+

Hero

+ +
+
+ + + + diff --git a/src/webui/js/main.js b/src/webui/js/main.js new file mode 100644 index 0000000..3f0113d --- /dev/null +++ b/src/webui/js/main.js @@ -0,0 +1,151 @@ +// Arrays to store gallery and hero images +let galleryImages = []; +let heroImages = []; + +// Load images data from server +async function loadData() { + try { + // Fetch gallery images from API + const galleryRes = await fetch('/api/gallery'); + galleryImages = await galleryRes.json(); + renderGallery(); // Render gallery images + + // Fetch hero images from API + const heroRes = await fetch('/api/hero'); + heroImages = await heroRes.json(); + renderHero(); // Render hero images + } catch(err) { + console.error(err); + alert("Error while loading images!"); + } +} + +// Render gallery images in the page +function renderGallery() { + const container = document.getElementById('gallery'); + container.innerHTML = ''; // Clear current content + galleryImages.forEach((img, i) => { + const div = document.createElement('div'); + div.className = 'photo'; + div.innerHTML = ` + + + + `; + container.appendChild(div); // Add image div to container + }); +} + +// Render hero images in the page +function renderHero() { + const container = document.getElementById('hero'); + container.innerHTML = ''; // Clear current content + heroImages.forEach((img, i) => { + const div = document.createElement('div'); + div.className = 'photo'; + div.innerHTML = ` + + + `; + container.appendChild(div); // Add image div to container + }); +} + +// Update tags for a gallery image +function updateTags(index, value) { + // Split tags by comma, trim spaces, and remove empty values + galleryImages[index].tags = value.split(',').map(t => t.trim()).filter(t => t); +} + +// Delete a gallery image +async function deleteGalleryImage(index) { + const img = galleryImages[index]; + try { + // Send only the filename to the server for deletion + const res = await fetch('/api/gallery/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ src: img.src.split('/').pop() }) // Keep only filename + }); + const data = await res.json(); + if (res.ok) { + // Remove image from array and re-render gallery + galleryImages.splice(index, 1); + renderGallery(); + await saveGallery(); // Save updated tags + } else { + alert("Error: " + data.error); + } + } catch(err) { + console.error(err); + alert("Server error!"); + } +} + +// Delete a hero image +async function deleteHeroImage(index) { + const img = heroImages[index]; + try { + // Send only the filename to the server for deletion + const res = await fetch('/api/hero/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ src: img.src.split('/').pop() }) // Keep only filename + }); + const data = await res.json(); + if (res.ok) { + // Remove image from array and re-render hero section + heroImages.splice(index, 1); + renderHero(); + await saveHero(); // Save updated hero images + } else { + alert("Error: " + data.error); + } + } catch(err) { + console.error(err); + alert("Server error!"); + } +} + +// Save gallery images (with tags) to the server +async function saveGallery() { + await fetch('/api/gallery/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(galleryImages) + }); +} + +// Save hero images to the server +async function saveHero() { + await fetch('/api/hero/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(heroImages) + }); +} + +// Save both gallery and hero changes +async function saveChanges() { + await saveGallery(); + await saveHero(); + alert('✅ Changes saved!'); +} + +// Refresh gallery images from the server folder +async function refreshGallery() { + await fetch('/api/gallery/refresh', { method: 'POST' }); + await loadData(); // Reload data after refresh + alert('🔄 Gallery updated from photos/gallery folder'); +} + +// Refresh hero images from the server folder +async function refreshHero() { + await fetch('/api/hero/refresh', { method: 'POST' }); + await loadData(); // Reload data after refresh + alert('🔄 Hero updated from photos/hero folder'); +} + +// Initial load of images when page opens +loadData(); diff --git a/src/webui/js/upload.js b/src/webui/js/upload.js new file mode 100644 index 0000000..045e625 --- /dev/null +++ b/src/webui/js/upload.js @@ -0,0 +1,61 @@ +// Upload handler for gallery images +document.getElementById('upload-gallery').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; // Exit if no file is selected + + // Create a FormData object to send the file + const formData = new FormData(); + formData.append('file', file); // Key must match what upload.py expects + + try { + // Send POST request to the gallery upload endpoint + const res = await fetch('/api/gallery/upload', { + method: 'POST', + body: formData + }); + + const data = await res.json(); + if (res.ok) { + alert('✅ Gallery image uploaded!'); + refreshGallery(); // Refresh the gallery list from the server + } else { + alert('Error: ' + data.error); // Show server error if upload failed + } + } catch (err) { + console.error(err); + alert('Server error!'); // Network or server failure + } finally { + e.target.value = ''; // Reset file input so the same file can be uploaded again if needed + } +}); + +// Upload handler for hero images +document.getElementById('upload-hero').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; // Exit if no file is selected + + // Create a FormData object to send the file + const formData = new FormData(); + formData.append('file', file); // Key must match what upload.py expects + + try { + // Send POST request to the hero upload endpoint + const res = await fetch('/api/hero/upload', { + method: 'POST', + body: formData + }); + + const data = await res.json(); + if (res.ok) { + alert('✅ Hero image uploaded!'); + refreshHero(); // Refresh the hero list from the server + } else { + alert('Error: ' + data.error); // Show server error if upload failed + } + } catch (err) { + console.error(err); + alert('Server error!'); // Network or server failure + } finally { + e.target.value = ''; // Reset file input so the same file can be uploaded again if needed + } +}); diff --git a/src/webui/style/style.css b/src/webui/style/style.css new file mode 100644 index 0000000..ec1f57a --- /dev/null +++ b/src/webui/style/style.css @@ -0,0 +1,98 @@ +body { + font-family: Arial, sans-serif; + margin: 20px; + background-color: #f9f9f9; + color: #333; +} + +h1, h2 { + color: #222; +} + +.toolbar { + margin-bottom: 20px; +} + +.toolbar button { + margin-right: 10px; + padding: 8px 12px; + border: none; + background-color: #4CAF50; + color: white; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; +} + +.toolbar button:hover { + background-color: #45a049; +} + +.upload-section { + margin-bottom: 30px; +} + +.upload-section label { + margin-right: 20px; + cursor: pointer; +} + +#gallery, #hero { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 15px; +} + +.photo { + background-color: white; + border: 1px solid #ddd; + border-radius: 6px; + padding: 10px; + text-align: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.photo img { + max-width: 100%; + border-radius: 4px; + margin-bottom: 8px; +} + +.photo input[type="text"] { + width: 100%; + padding: 4px 6px; + margin-bottom: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.photo button { + padding: 4px 8px; + border: none; + background-color: #f44336; + color: white; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.photo button:hover { + background-color: #d32f2f; +} + +/* Responsive adjustments */ +@media (max-width: 500px) { + body { + margin: 10px; + } + + .toolbar button { + margin-bottom: 8px; + width: 100%; + } + + .upload-section label { + display: block; + margin-bottom: 10px; + } +}