Compare commits
	
		
			18 Commits
		
	
	
		
			v1.2
			...
			d3484a4b50
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					d3484a4b50 | ||
| 
						 | 
					9d37b0a60f | ||
| 
						 | 
					080eb2593d | ||
| 
						 | 
					73a0dd0ce6 | ||
| 
						 | 
					97645b06fa | ||
| 
						 | 
					142c042b86 | ||
| 
						 | 
					041db66b3d | ||
| 
						 | 
					1b0b228273 | ||
| 41450837f2 | |||
| 4edeb8709a | |||
| 6fc573c510 | |||
| 43c007c1fe | |||
| dfbd532efd | |||
| efe1bbca29 | |||
| 7e1a5e659f | |||
| f5a5aefd09 | |||
| f76420b2c3 | |||
| 3901bf8acf | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,5 @@
 | 
				
			|||||||
.*
 | 
					.*
 | 
				
			||||||
 | 
					!.sh
 | 
				
			||||||
!.gitignore
 | 
					!.gitignore
 | 
				
			||||||
output/
 | 
					output/
 | 
				
			||||||
__pycache__/
 | 
					__pycache__/
 | 
				
			||||||
							
								
								
									
										19
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					FROM python:3.11-slim
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY requirements.txt .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN pip install --no-cache-dir -r requirements.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
 | 
				
			||||||
 | 
					    printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENTRYPOINT ["/app/entrypoint.sh"]
 | 
				
			||||||
							
								
								
									
										31
									
								
								README.MD
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								README.MD
									
									
									
									
									
								
							@@ -23,9 +23,8 @@ The project includes two thoughtfully designed themes—one modern, one minimali
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## 📌 Table of Contents
 | 
					## 📌 Table of Contents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- [✨ Features](#-features)  
 | 
					- [✨ Features](#-features)
 | 
				
			||||||
- [🐍 Python Installation](#-python-installation)  
 | 
					- [🐳 Docker or 🐍 Python Installation](#-docker-or--python-installation)
 | 
				
			||||||
- [⚙️ Configuration](#-configuration)  
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## ✨ Features
 | 
					## ✨ Features
 | 
				
			||||||
@@ -57,28 +56,6 @@ The project includes two thoughtfully designed themes—one modern, one minimali
 | 
				
			|||||||
- *(Optional)* Converts images to WebP format for optimized performance  
 | 
					- *(Optional)* Converts images to WebP format for optimized performance  
 | 
				
			||||||
- Outputs a complete static website ready to deploy on any web server
 | 
					- Outputs a complete static website ready to deploy on any web server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 🐳 Docker or 🐍 Python Installation
 | 
				
			||||||
## 🐍 Python Installation
 | 
					For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
 | 
				
			||||||
 | 
					 | 
				
			||||||
Run the Python scripts directly with the following prerequisites:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Prerequisites
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- Git
 | 
					 | 
				
			||||||
- Python 3.11 or above
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Installation Steps
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```sh
 | 
					 | 
				
			||||||
git clone https://git.djeex.fr/Djeex/lumeex.git
 | 
					 | 
				
			||||||
cd lumeex
 | 
					 | 
				
			||||||
python3 -m venv .venv
 | 
					 | 
				
			||||||
source .venv/bin/activate
 | 
					 | 
				
			||||||
pip install -r requirements.txt
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
You are now ready to use Lumeex!
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ⚙️ Configuration
 | 
					 | 
				
			||||||
For comprehensive documentation on configuration options, customization, and demos, please visit:
 | 
					 | 
				
			||||||
https://lumeex.djeex.fr
 | 
					https://lumeex.djeex.fr
 | 
				
			||||||
							
								
								
									
										187
									
								
								build.py
									
									
									
									
									
								
							
							
						
						
									
										187
									
								
								build.py
									
									
									
									
									
								
							@@ -1,187 +1,6 @@
 | 
				
			|||||||
import logging
 | 
					import logging
 | 
				
			||||||
from datetime import datetime
 | 
					from src.py.builder.site_builder import build
 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from shutil import copyfile
 | 
					 | 
				
			||||||
from PIL import Image
 | 
					 | 
				
			||||||
from src.py.utils import ensure_dir, copy_assets, load_yaml, load_theme_config
 | 
					 | 
				
			||||||
from src.py.css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
 | 
					 | 
				
			||||||
from src.py.image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
 | 
					 | 
				
			||||||
from src.py.html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Configure logging to display only the messages
 | 
					 | 
				
			||||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Define key directories used throughout the script
 | 
					 | 
				
			||||||
SRC_DIR = Path.cwd()
 | 
					 | 
				
			||||||
BUILD_DIR = SRC_DIR / "output"
 | 
					 | 
				
			||||||
TEMPLATE_DIR = SRC_DIR / "src/templates"
 | 
					 | 
				
			||||||
IMG_DIR = SRC_DIR / "config/photos"
 | 
					 | 
				
			||||||
JS_DIR = SRC_DIR / "src/public/js"
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def build():
 | 
					 | 
				
			||||||
    logging.info("🚀 Starting build...")
 | 
					 | 
				
			||||||
    ensure_dir(BUILD_DIR)
 | 
					 | 
				
			||||||
    copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Defining build vars
 | 
					 | 
				
			||||||
    build_date = datetime.now().strftime("%Y%m%d%H%M%S")
 | 
					 | 
				
			||||||
    build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 | 
					 | 
				
			||||||
    site_vars = load_yaml(SITE_FILE)
 | 
					 | 
				
			||||||
    gallery_vars = load_yaml(GALLERY_FILE)
 | 
					 | 
				
			||||||
    build_section = site_vars.get("build", {})
 | 
					 | 
				
			||||||
    theme_name = site_vars.get("build", {}).get("theme", "default")
 | 
					 | 
				
			||||||
    theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
 | 
					 | 
				
			||||||
    fonts_dir = theme_dir / "fonts"
 | 
					 | 
				
			||||||
    theme_css_path = theme_dir / "theme.css"
 | 
					 | 
				
			||||||
    canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
 | 
					 | 
				
			||||||
    canonical_home = f"{canonical_url}/"
 | 
					 | 
				
			||||||
    canonical_legals = f"{canonical_url}/legals/"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Copying theme.css if existing
 | 
					 | 
				
			||||||
    if theme_css_path.exists():
 | 
					 | 
				
			||||||
        dest_theme_css = BUILD_DIR / "style" / "theme.css"
 | 
					 | 
				
			||||||
        dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        copyfile(theme_css_path, dest_theme_css)
 | 
					 | 
				
			||||||
        theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
 | 
					 | 
				
			||||||
        logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        theme_css = ""
 | 
					 | 
				
			||||||
        logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
 | 
					 | 
				
			||||||
    generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
 | 
					 | 
				
			||||||
    generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
 | 
					 | 
				
			||||||
    generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Converting and resizing images if enabled
 | 
					 | 
				
			||||||
    convert_images = build_section.get("convert_images", True)
 | 
					 | 
				
			||||||
    resize_images = build_section.get("resize_images", True)
 | 
					 | 
				
			||||||
    logging.info(f"[~] convert_images = {convert_images}")
 | 
					 | 
				
			||||||
    logging.info(f"[~] resize_images = {resize_images}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    hero_images = gallery_vars.get("hero", {}).get("images", [])
 | 
					 | 
				
			||||||
    gallery_images = gallery_vars.get("gallery", {}).get("images", [])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if convert_images:
 | 
					 | 
				
			||||||
        process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
 | 
					 | 
				
			||||||
        process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
 | 
					 | 
				
			||||||
        copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if "hero" not in site_vars:
 | 
					 | 
				
			||||||
        site_vars["hero"] = {}  # Initialize an empty hero section
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Adding menu
 | 
					 | 
				
			||||||
    menu_html = "\n".join(
 | 
					 | 
				
			||||||
        f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
 | 
					 | 
				
			||||||
        for item in site_vars.get("menu", {}).get("items", [])
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    site_vars["hero"]["menu_items"] = menu_html
 | 
					 | 
				
			||||||
    if "footer" in site_vars:
 | 
					 | 
				
			||||||
        site_vars["footer"]["menu_items"] = menu_html
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Adding Google fonts if existing
 | 
					 | 
				
			||||||
    google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
 | 
					 | 
				
			||||||
    logging.info(f"[✓] Google Fonts link generated:\n{google_fonts_link}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Generating thumbnail
 | 
					 | 
				
			||||||
    thumbnail_path = site_vars.get("social", {}).get("thumbnail")
 | 
					 | 
				
			||||||
    if thumbnail_path:
 | 
					 | 
				
			||||||
        src_thumb = IMG_DIR / thumbnail_path
 | 
					 | 
				
			||||||
        dest_thumb_dir = BUILD_DIR / "img" / "social"
 | 
					 | 
				
			||||||
        dest_thumb_dir.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            img = Image.open(src_thumb)
 | 
					 | 
				
			||||||
            img = img.convert("RGB")
 | 
					 | 
				
			||||||
            img = img.resize((1200, 630), Image.LANCZOS)
 | 
					 | 
				
			||||||
            img.save(dest_thumb, "JPEG", quality=90)
 | 
					 | 
				
			||||||
            logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
 | 
					 | 
				
			||||||
        except Exception as e:
 | 
					 | 
				
			||||||
            logging.error(f"[✗] Failed to process thumbnail: {e}")
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        logging.warning("[~] No thumbnail found in social section")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Defining head variables
 | 
					 | 
				
			||||||
    head_vars = dict(site_vars.get("info", {}))
 | 
					 | 
				
			||||||
    head_vars.update(theme_vars.get("colors", {}))
 | 
					 | 
				
			||||||
    head_vars.update(site_vars.get("social", {}))
 | 
					 | 
				
			||||||
    head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
 | 
					 | 
				
			||||||
    head_vars["google_fonts_link"] = google_fonts_link
 | 
					 | 
				
			||||||
    head_vars["font_preloads"] = "\n".join(preload_links)
 | 
					 | 
				
			||||||
    head_vars["theme_css"] = theme_css
 | 
					 | 
				
			||||||
    head_vars["build_date"] = build_date
 | 
					 | 
				
			||||||
    head_vars["canonical"] = canonical_home
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Render the home page
 | 
					 | 
				
			||||||
    head = render_template(TEMPLATE_DIR / "head.html", head_vars)
 | 
					 | 
				
			||||||
    hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
 | 
					 | 
				
			||||||
    footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
 | 
					 | 
				
			||||||
    gallery_html = render_gallery_images(gallery_images)
 | 
					 | 
				
			||||||
    gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    signature = f"<!-- Build with Lumeex v1.2 | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
 | 
					 | 
				
			||||||
    body = f"""
 | 
					 | 
				
			||||||
    <body>
 | 
					 | 
				
			||||||
        <div class="page-loader"><div class="spinner"></div></div>
 | 
					 | 
				
			||||||
        {hero}
 | 
					 | 
				
			||||||
        {gallery}
 | 
					 | 
				
			||||||
        {footer}
 | 
					 | 
				
			||||||
    </body>
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    output_file = BUILD_DIR / "index.html"
 | 
					 | 
				
			||||||
    with open(output_file, "w", encoding="utf-8") as f:
 | 
					 | 
				
			||||||
        f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
 | 
					 | 
				
			||||||
    logging.info(f"[✓] HTML generated: {output_file}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Rendering legals page
 | 
					 | 
				
			||||||
    head_vars["canonical"] = canonical_legals
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    legals_vars = site_vars.get("legals", {})
 | 
					 | 
				
			||||||
    if legals_vars:
 | 
					 | 
				
			||||||
        head = render_template(TEMPLATE_DIR / "head.html", head_vars)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        ip_paragraphs = legals_vars.get("intellectual_property", [])
 | 
					 | 
				
			||||||
        paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
 | 
					 | 
				
			||||||
        legals_context = {
 | 
					 | 
				
			||||||
            "hoster_name": legals_vars.get("hoster_name", ""),
 | 
					 | 
				
			||||||
            "hoster_adress": legals_vars.get("hoster_adress", ""),
 | 
					 | 
				
			||||||
            "hoster_contact": legals_vars.get("hoster_contact", ""),
 | 
					 | 
				
			||||||
            "intellectual_property": paragraphs_html,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
 | 
					 | 
				
			||||||
        legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
 | 
					 | 
				
			||||||
        output_legals = BUILD_DIR / "legals" / "index.html"
 | 
					 | 
				
			||||||
        output_legals.parent.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        with open(output_legals, "w", encoding="utf-8") as f:
 | 
					 | 
				
			||||||
            f.write(legals_html)
 | 
					 | 
				
			||||||
        logging.info(f"[✓] Legals page generated: {output_legals}")
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        logging.warning("[~] No legals section found in site.yaml")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Hero carrousel generator
 | 
					 | 
				
			||||||
    if hero_images:
 | 
					 | 
				
			||||||
        generate_gallery_json_from_images(hero_images, BUILD_DIR)
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        logging.warning("[~] No hero images found, skipping JSON generation.")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Sitemap and robot.txt generator
 | 
					 | 
				
			||||||
    site_info = site_vars.get("info", {})
 | 
					 | 
				
			||||||
    canonical_url = site_info.get("canonical", "").rstrip("/")
 | 
					 | 
				
			||||||
    if canonical_url:
 | 
					 | 
				
			||||||
        allowed_pages = ["/", "/legals/"]
 | 
					 | 
				
			||||||
        generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
 | 
					 | 
				
			||||||
        generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    logging.info("✅ Build complete.")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
    build()
 | 
					    logging.basicConfig(level=logging.INFO, format="%(message)s")
 | 
				
			||||||
    
 | 
					    build()
 | 
				
			||||||
@@ -1,7 +1,4 @@
 | 
				
			|||||||
# Use gallery.py to automatically add photos stored in your /config/photos/gallery folder
 | 
					 | 
				
			||||||
# Add tags to your photos as shown below
 | 
					 | 
				
			||||||
# remove the # before [] if you removed all images to use gallery.py again
 | 
					 | 
				
			||||||
hero:
 | 
					hero:
 | 
				
			||||||
  images: []
 | 
					  images: []
 | 
				
			||||||
gallery:
 | 
					gallery:
 | 
				
			||||||
  images: []
 | 
					  images: []
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										78
									
								
								docker/.sh/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								docker/.sh/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					set -e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CYAN="\033[1;36m"
 | 
				
			||||||
 | 
					NC="\033[0m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					copy_default_config() {
 | 
				
			||||||
 | 
					  echo "Checking configuration directory..."
 | 
				
			||||||
 | 
					  if [ ! -d "/app/config" ]; then
 | 
				
			||||||
 | 
					    mkdir -p /app/config
 | 
				
			||||||
 | 
					  fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  echo "Checking if default config files need to be copied..."
 | 
				
			||||||
 | 
					  files_copied=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for file in /app/default/*; do
 | 
				
			||||||
 | 
					    filename=$(basename "$file")
 | 
				
			||||||
 | 
					    target="/app/config/$filename"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if [ ! -e "$target" ]; then
 | 
				
			||||||
 | 
					      echo "Copying default config file: $filename"
 | 
				
			||||||
 | 
					      cp -r "$file" "$target"
 | 
				
			||||||
 | 
					      files_copied=true
 | 
				
			||||||
 | 
					    fi
 | 
				
			||||||
 | 
					  done
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if [ "$files_copied" = true ]; then
 | 
				
			||||||
 | 
					    echo "Default configuration files copied successfully."
 | 
				
			||||||
 | 
					  else
 | 
				
			||||||
 | 
					    echo "No default files needed to be copied."
 | 
				
			||||||
 | 
					  fi
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					start_server() {
 | 
				
			||||||
 | 
					  # Clean up old FIFOs
 | 
				
			||||||
 | 
					  [ -p /tmp/build_logs_fifo ] && rm /tmp/build_logs_fifo
 | 
				
			||||||
 | 
					  [ -p /tmp/build_logs_fifo2 ] && rm /tmp/build_logs_fifo2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  mkfifo /tmp/build_logs_fifo
 | 
				
			||||||
 | 
					  mkfifo /tmp/build_logs_fifo2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cat /tmp/build_logs_fifo >&2 &
 | 
				
			||||||
 | 
					  cat /tmp/build_logs_fifo2 >&2 &
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  echo "Starting 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
 | 
				
			||||||
 | 
					  wait $SERVER_PID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if [ $# -eq 0 ]; then
 | 
				
			||||||
 | 
					  echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
 | 
				
			||||||
 | 
					  echo -e "${CYAN}│${NC}          Lum${CYAN}eex${NC} - Version 1.3.1${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}"
 | 
				
			||||||
 | 
					  echo -e "${CYAN}│${NC} Documentation: https://lumeex.djeex.fr    ${CYAN}│${NC}"
 | 
				
			||||||
 | 
					  echo -e "${CYAN}╰───────────────────────────────────────────╯${NC}"
 | 
				
			||||||
 | 
					  copy_default_config
 | 
				
			||||||
 | 
					  start_server
 | 
				
			||||||
 | 
					fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					case "$1" in
 | 
				
			||||||
 | 
					  build)
 | 
				
			||||||
 | 
					    echo "Running build.py..."
 | 
				
			||||||
 | 
					    python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
 | 
				
			||||||
 | 
					    ;;
 | 
				
			||||||
 | 
					  gallery)
 | 
				
			||||||
 | 
					    echo "Running gallery.py..."
 | 
				
			||||||
 | 
					    python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
 | 
				
			||||||
 | 
					    ;;
 | 
				
			||||||
 | 
					  *)
 | 
				
			||||||
 | 
					    echo "Unknown command: $1"
 | 
				
			||||||
 | 
					    exec "$@"
 | 
				
			||||||
 | 
					    ;;
 | 
				
			||||||
 | 
					esac
 | 
				
			||||||
							
								
								
									
										10
									
								
								docker/docker-compose.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docker/docker-compose.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					services:
 | 
				
			||||||
 | 
					  lumeex:
 | 
				
			||||||
 | 
					    container_name: lmx
 | 
				
			||||||
 | 
					    build: ..
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - ../config:/app/config  # mount config directory
 | 
				
			||||||
 | 
					      - ../output:/app/output  # mount output directory
 | 
				
			||||||
 | 
					    ports:
 | 
				
			||||||
 | 
					      - "3000:3000"
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
							
								
								
									
										114
									
								
								gallery.py
									
									
									
									
									
								
							
							
						
						
									
										114
									
								
								gallery.py
									
									
									
									
									
								
							@@ -1,113 +1,7 @@
 | 
				
			|||||||
import yaml
 | 
					import logging
 | 
				
			||||||
import os
 | 
					from src.py.builder.gallery_builder import update_gallery, update_hero
 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# YAML file paths
 | 
					 | 
				
			||||||
GALLERY_YAML = "config/gallery.yaml"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Image directories
 | 
					 | 
				
			||||||
GALLERY_DIR = Path("config/photos/gallery")
 | 
					 | 
				
			||||||
HERO_DIR = Path("config/photos/hero")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def load_yaml(path):
 | 
					 | 
				
			||||||
    print(f"[→] Loading {path}...")
 | 
					 | 
				
			||||||
    if not os.path.exists(path):
 | 
					 | 
				
			||||||
        print(f"[✗] File not found: {path}")
 | 
					 | 
				
			||||||
        return {}
 | 
					 | 
				
			||||||
    with open(path, "r", encoding="utf-8") as f:
 | 
					 | 
				
			||||||
        data = yaml.safe_load(f) or {}
 | 
					 | 
				
			||||||
        images = data.get("images", []) or []
 | 
					 | 
				
			||||||
        print(f"[✓] Loaded {len(images)} image(s) from {path}")
 | 
					 | 
				
			||||||
        return data
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def save_yaml(data, path):
 | 
					 | 
				
			||||||
    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):
 | 
					 | 
				
			||||||
    return sorted([
 | 
					 | 
				
			||||||
        str(p.relative_to(directory.parent)).replace("\\", "/")
 | 
					 | 
				
			||||||
        for p in directory.rglob("*")
 | 
					 | 
				
			||||||
        if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
 | 
					 | 
				
			||||||
    ])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def update_gallery():
 | 
					 | 
				
			||||||
    print("\n=== Updating gallery.yaml (gallery section) ===")
 | 
					 | 
				
			||||||
    gallery = load_yaml(GALLERY_YAML)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
 | 
					 | 
				
			||||||
    gallery_section = gallery.get("gallery", {})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
 | 
					 | 
				
			||||||
    gallery_images = gallery_section.get("images", [])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    all_images = set(get_all_image_paths(GALLERY_DIR))
 | 
					 | 
				
			||||||
    known_images = {img["src"] for img in gallery_images}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Add new images
 | 
					 | 
				
			||||||
    new_images = [
 | 
					 | 
				
			||||||
        {"src": path, "tags": []}
 | 
					 | 
				
			||||||
        for path in all_images
 | 
					 | 
				
			||||||
        if path not in known_images
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    if new_images:
 | 
					 | 
				
			||||||
        gallery_images.extend(new_images)
 | 
					 | 
				
			||||||
        print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Remove deleted images
 | 
					 | 
				
			||||||
    deleted_images = known_images - all_images
 | 
					 | 
				
			||||||
    if deleted_images:
 | 
					 | 
				
			||||||
        gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
 | 
					 | 
				
			||||||
        print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Update the 'gallery' section with the modified 'images' list
 | 
					 | 
				
			||||||
    gallery_section["images"] = gallery_images
 | 
					 | 
				
			||||||
    gallery["gallery"] = gallery_section
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    save_yaml(gallery, GALLERY_YAML)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if not new_images and not deleted_images:
 | 
					 | 
				
			||||||
        print("[✓] No changes to gallery.yaml (gallery)")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def update_hero():
 | 
					 | 
				
			||||||
    print("\n=== Updating gallery.yaml (hero section) ===")
 | 
					 | 
				
			||||||
    gallery = load_yaml(GALLERY_YAML)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
 | 
					 | 
				
			||||||
    hero_section = gallery.get("hero", {})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
 | 
					 | 
				
			||||||
    hero_images = hero_section.get("images", [])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    all_images = set(get_all_image_paths(HERO_DIR))
 | 
					 | 
				
			||||||
    known_images = {img["src"] for img in hero_images}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Add new images
 | 
					 | 
				
			||||||
    new_images = [
 | 
					 | 
				
			||||||
        {"src": path}
 | 
					 | 
				
			||||||
        for path in all_images
 | 
					 | 
				
			||||||
        if path not in known_images
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    if new_images:
 | 
					 | 
				
			||||||
        hero_images.extend(new_images)
 | 
					 | 
				
			||||||
        print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Remove deleted images
 | 
					 | 
				
			||||||
    deleted_images = known_images - all_images
 | 
					 | 
				
			||||||
    if deleted_images:
 | 
					 | 
				
			||||||
        hero_images = [img for img in hero_images if img["src"] not in deleted_images]
 | 
					 | 
				
			||||||
        print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Update the 'hero' section with the modified 'images' list
 | 
					 | 
				
			||||||
    hero_section["images"] = hero_images
 | 
					 | 
				
			||||||
    gallery["hero"] = hero_section
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    save_yaml(gallery, GALLERY_YAML)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if not new_images and not deleted_images:
 | 
					 | 
				
			||||||
        print("[✓] No changes to gallery.yaml (hero)")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    logging.basicConfig(level=logging.INFO, format="%(message)s")
 | 
				
			||||||
    update_gallery()
 | 
					    update_gallery()
 | 
				
			||||||
    update_hero()
 | 
					    update_hero()
 | 
				
			||||||
@@ -1,2 +1,3 @@
 | 
				
			|||||||
pyyaml
 | 
					pyyaml
 | 
				
			||||||
pillow
 | 
					pillow
 | 
				
			||||||
 | 
					flask
 | 
				
			||||||
@@ -17,24 +17,11 @@ const setupLoader = () => {
 | 
				
			|||||||
  window.addEventListener('load', () => {
 | 
					  window.addEventListener('load', () => {
 | 
				
			||||||
    setTimeout(() => {
 | 
					    setTimeout(() => {
 | 
				
			||||||
      const loader = document.querySelector('.page-loader');
 | 
					      const loader = document.querySelector('.page-loader');
 | 
				
			||||||
      if (loader) {
 | 
					      if (loader) loader.classList.add('hidden');
 | 
				
			||||||
        loader.classList.add('hidden');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }, 50);
 | 
					    }, 50);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Gallery randomizer to shuffle gallery sections on page load
 | 
					 | 
				
			||||||
const shuffleGallery = () => {
 | 
					 | 
				
			||||||
  const gallery = document.querySelector('.gallery');
 | 
					 | 
				
			||||||
  if (!gallery) return;
 | 
					 | 
				
			||||||
  const sections = Array.from(gallery.querySelectorAll('.section'));
 | 
					 | 
				
			||||||
  while (sections.length) {
 | 
					 | 
				
			||||||
    const randomIndex = Math.floor(Math.random() * sections.length);
 | 
					 | 
				
			||||||
    gallery.appendChild(sections.splice(randomIndex, 1)[0]);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Hero background randomizer
 | 
					// Hero background randomizer
 | 
				
			||||||
const randomizeHeroBackground = () => {
 | 
					const randomizeHeroBackground = () => {
 | 
				
			||||||
  const heroBg = document.querySelector(".hero-background");
 | 
					  const heroBg = document.querySelector(".hero-background");
 | 
				
			||||||
@@ -65,32 +52,74 @@ const randomizeHeroBackground = () => {
 | 
				
			|||||||
    .catch(console.error);
 | 
					    .catch(console.error);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Gallery randomizer to shuffle gallery sections on page load
 | 
				
			||||||
 | 
					const shuffleGallery = () => {
 | 
				
			||||||
 | 
					  const gallery = document.querySelector('.gallery');
 | 
				
			||||||
 | 
					  if (!gallery) return;
 | 
				
			||||||
 | 
					  const sections = Array.from(gallery.querySelectorAll('.section'));
 | 
				
			||||||
 | 
					  while (sections.length) {
 | 
				
			||||||
 | 
					    const randomIndex = Math.floor(Math.random() * sections.length);
 | 
				
			||||||
 | 
					    gallery.appendChild(sections.splice(randomIndex, 1)[0]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Tags filter functionality
 | 
					// Tags filter functionality
 | 
				
			||||||
const setupTagFilter = () => {
 | 
					const setupTagFilter = () => {
 | 
				
			||||||
 | 
					  const galleryContainer = document.querySelector('#gallery');
 | 
				
			||||||
  const allSections = document.querySelectorAll('.section[data-tags]');
 | 
					  const allSections = document.querySelectorAll('.section[data-tags]');
 | 
				
			||||||
  const allTags = document.querySelectorAll('.tag');
 | 
					  const allTags = document.querySelectorAll('.tag');
 | 
				
			||||||
  let activeTags = [];
 | 
					  let activeTags = [];
 | 
				
			||||||
 | 
					  let lastClickedTag = null; // mémorise le dernier tag cliqué
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const applyFilter = () => {
 | 
					  const applyFilter = () => {
 | 
				
			||||||
 | 
					    let filteredSections = [];
 | 
				
			||||||
 | 
					    let matchingSection = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    allSections.forEach((section) => {
 | 
					    allSections.forEach((section) => {
 | 
				
			||||||
      const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
 | 
					      const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
 | 
				
			||||||
      const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
 | 
					      const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
 | 
				
			||||||
      section.style.display = hasAllTags ? '' : 'none';
 | 
					      section.style.display = hasAllTags ? '' : 'none';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (hasAllTags) {
 | 
				
			||||||
 | 
					        filteredSections.push(section);
 | 
				
			||||||
 | 
					        if (lastClickedTag && sectionTags.includes(lastClickedTag) && !matchingSection) {
 | 
				
			||||||
 | 
					          matchingSection = section;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Réorganise : la photo correspondante au dernier tag cliqué en premier
 | 
				
			||||||
 | 
					    if (matchingSection && galleryContainer.contains(matchingSection)) {
 | 
				
			||||||
 | 
					      galleryContainer.prepend(matchingSection);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Met à jour le style des tags
 | 
				
			||||||
    allTags.forEach((tagEl) => {
 | 
					    allTags.forEach((tagEl) => {
 | 
				
			||||||
      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
					      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
				
			||||||
      tagEl.classList.toggle('active', activeTags.includes(tagText));
 | 
					      tagEl.classList.toggle('active', activeTags.includes(tagText));
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Met à jour l'URL
 | 
				
			||||||
    const base = window.location.pathname;
 | 
					    const base = window.location.pathname;
 | 
				
			||||||
    const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
 | 
					    const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
 | 
				
			||||||
    window.history.pushState({}, '', base + query);
 | 
					    window.history.pushState({}, '', base + query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Scroll jusqu'à la galerie
 | 
				
			||||||
 | 
					    if (galleryContainer) {
 | 
				
			||||||
 | 
					      galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  allTags.forEach((tagEl) => {
 | 
					  allTags.forEach((tagEl) => {
 | 
				
			||||||
    tagEl.addEventListener('click', () => {
 | 
					    tagEl.addEventListener('click', () => {
 | 
				
			||||||
      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
					      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
				
			||||||
      activeTags = activeTags.includes(tagText)
 | 
					      lastClickedTag = tagText; // mémorise le dernier tag cliqué
 | 
				
			||||||
        ? activeTags.filter((t) => t !== tagText)
 | 
					
 | 
				
			||||||
        : [...activeTags, tagText];
 | 
					      if (activeTags.includes(tagText)) {
 | 
				
			||||||
 | 
					        activeTags = activeTags.filter((t) => t !== tagText);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        activeTags.push(tagText);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      applyFilter();
 | 
					      applyFilter();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@@ -100,29 +129,24 @@ const setupTagFilter = () => {
 | 
				
			|||||||
    const urlTags = params.get('tag');
 | 
					    const urlTags = params.get('tag');
 | 
				
			||||||
    if (urlTags) {
 | 
					    if (urlTags) {
 | 
				
			||||||
      activeTags = urlTags.split(',').map((t) => t.toLowerCase());
 | 
					      activeTags = urlTags.split(',').map((t) => t.toLowerCase());
 | 
				
			||||||
 | 
					      lastClickedTag = activeTags[activeTags.length - 1] || null;
 | 
				
			||||||
      applyFilter();
 | 
					      applyFilter();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Disable right-click context menu and image dragging
 | 
					// Disable right click and drag
 | 
				
			||||||
const disableRightClickAndDrag = () => {
 | 
					const disableRightClickAndDrag = () => {
 | 
				
			||||||
  document.addEventListener("contextmenu", (e) => e.preventDefault());
 | 
					  document.addEventListener('contextmenu', (e) => e.preventDefault());
 | 
				
			||||||
  document.addEventListener("dragstart", (e) => {
 | 
					  document.addEventListener('dragstart', (e) => e.preventDefault());
 | 
				
			||||||
    if (e.target.tagName === "IMG") {
 | 
					 | 
				
			||||||
      e.preventDefault();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Scroll-to-top button functionality
 | 
					// Scroll to top button
 | 
				
			||||||
const setupScrollToTopButton = () => {
 | 
					const setupScrollToTopButton = () => {
 | 
				
			||||||
  const scrollBtn = document.getElementById("scrollToTop");
 | 
					  const scrollToTopButton = document.querySelector('.scroll-to-top');
 | 
				
			||||||
  window.addEventListener("scroll", () => {
 | 
					  if (!scrollToTopButton) return;
 | 
				
			||||||
    scrollBtn.style.display = window.scrollY > 300 ? "block" : "none";
 | 
					  scrollToTopButton.addEventListener('click', () => {
 | 
				
			||||||
  });
 | 
					    window.scrollTo({ top: 0, behavior: 'smooth' });
 | 
				
			||||||
  scrollBtn.addEventListener("click", () => {
 | 
					 | 
				
			||||||
    window.scrollTo({ top: 0, behavior: "smooth" });
 | 
					 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -333,8 +333,12 @@ h2 {
 | 
				
			|||||||
    font-size: 22px;
 | 
					    font-size: 22px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Sections */
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.gallery {
 | 
				
			||||||
 | 
							padding-top: 15px;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/* Sections */
 | 
				
			||||||
.section {
 | 
					.section {
 | 
				
			||||||
	max-width: 1140px;
 | 
						max-width: 1140px;
 | 
				
			||||||
	margin:auto;
 | 
						margin:auto;
 | 
				
			||||||
@@ -508,4 +512,9 @@ h2 {
 | 
				
			|||||||
		padding-left: 0;
 | 
							padding-left: 0;
 | 
				
			||||||
		margin-top: 60px;
 | 
							margin-top: 60px;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						.gallery {
 | 
				
			||||||
 | 
						    margin: 10% 5% 0 5%;
 | 
				
			||||||
 | 
							padding-top: 15px;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -3,6 +3,7 @@ from pathlib import Path
 | 
				
			|||||||
from shutil import copyfile
 | 
					from shutil import copyfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_css_variables(colors_dict, output_path):
 | 
					def generate_css_variables(colors_dict, output_path):
 | 
				
			||||||
 | 
					    """Generate css variables for theme colors"""
 | 
				
			||||||
    css_lines = [":root {"]
 | 
					    css_lines = [":root {"]
 | 
				
			||||||
    for key, value in colors_dict.items():
 | 
					    for key, value in colors_dict.items():
 | 
				
			||||||
        css_lines.append(f"  --color-{key.replace('_', '-')}: {value};")
 | 
					        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}")
 | 
					    logging.info(f"[✓] CSS variables written to {output_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
 | 
					def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
 | 
				
			||||||
 | 
					    """Generate css variables fonts"""
 | 
				
			||||||
    font_files = list(fonts_dir.glob("*"))
 | 
					    font_files = list(fonts_dir.glob("*"))
 | 
				
			||||||
    font_faces = {}
 | 
					    font_faces = {}
 | 
				
			||||||
    preload_links = []
 | 
					    preload_links = []
 | 
				
			||||||
@@ -57,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
 | 
				
			|||||||
    return preload_links
 | 
					    return preload_links
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_google_fonts_link(fonts):
 | 
					def generate_google_fonts_link(fonts):
 | 
				
			||||||
 | 
					    """Generate src link for Google fonts"""
 | 
				
			||||||
    if not fonts:
 | 
					    if not fonts:
 | 
				
			||||||
        return ""
 | 
					        return ""
 | 
				
			||||||
    families = []
 | 
					    families = []
 | 
				
			||||||
							
								
								
									
										114
									
								
								src/py/builder/gallery_builder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/py/builder/gallery_builder.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
				
			|||||||
 | 
					import yaml
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# YAML file paths
 | 
				
			||||||
 | 
					GALLERY_YAML = "config/gallery.yaml"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Image directories
 | 
				
			||||||
 | 
					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}")
 | 
				
			||||||
 | 
					        return {}
 | 
				
			||||||
 | 
					    with open(path, "r", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					        data = yaml.safe_load(f) or {}
 | 
				
			||||||
 | 
					        images = data.get("images", []) or []
 | 
				
			||||||
 | 
					        print(f"[✓] Loaded {len(images)} image(s) from {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("*")
 | 
				
			||||||
 | 
					        if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
 | 
				
			||||||
 | 
					    ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def update_gallery():
 | 
				
			||||||
 | 
					    """Update the gallery photo list"""
 | 
				
			||||||
 | 
					    print("\n=== Updating gallery.yaml (gallery section) ===")
 | 
				
			||||||
 | 
					    gallery = load_yaml(GALLERY_YAML)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
 | 
				
			||||||
 | 
					    gallery_section = gallery.get("gallery", {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
 | 
				
			||||||
 | 
					    gallery_images = gallery_section.get("images", [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    all_images = set(get_all_image_paths(GALLERY_DIR))
 | 
				
			||||||
 | 
					    known_images = {img["src"] for img in gallery_images}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Add new images
 | 
				
			||||||
 | 
					    new_images = [
 | 
				
			||||||
 | 
					        {"src": path, "tags": []}
 | 
				
			||||||
 | 
					        for path in all_images
 | 
				
			||||||
 | 
					        if path not in known_images
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    if new_images:
 | 
				
			||||||
 | 
					        gallery_images.extend(new_images)
 | 
				
			||||||
 | 
					        print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Remove deleted images
 | 
				
			||||||
 | 
					    deleted_images = known_images - all_images
 | 
				
			||||||
 | 
					    if deleted_images:
 | 
				
			||||||
 | 
					        gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
 | 
				
			||||||
 | 
					        print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Update the 'gallery' section with the modified 'images' list
 | 
				
			||||||
 | 
					    gallery_section["images"] = gallery_images
 | 
				
			||||||
 | 
					    gallery["gallery"] = gallery_section
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    save_yaml(gallery, GALLERY_YAML)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not new_images and not deleted_images:
 | 
				
			||||||
 | 
					        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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
 | 
				
			||||||
 | 
					    hero_section = gallery.get("hero", {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
 | 
				
			||||||
 | 
					    hero_images = hero_section.get("images", [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    all_images = set(get_all_image_paths(HERO_DIR))
 | 
				
			||||||
 | 
					    known_images = {img["src"] for img in hero_images}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Add new images
 | 
				
			||||||
 | 
					    new_images = [
 | 
				
			||||||
 | 
					        {"src": path}
 | 
				
			||||||
 | 
					        for path in all_images
 | 
				
			||||||
 | 
					        if path not in known_images
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    if new_images:
 | 
				
			||||||
 | 
					        hero_images.extend(new_images)
 | 
				
			||||||
 | 
					        print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Remove deleted images
 | 
				
			||||||
 | 
					    deleted_images = known_images - all_images
 | 
				
			||||||
 | 
					    if deleted_images:
 | 
				
			||||||
 | 
					        hero_images = [img for img in hero_images if img["src"] not in deleted_images]
 | 
				
			||||||
 | 
					        print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Update the 'hero' section with the modified 'images' list
 | 
				
			||||||
 | 
					    hero_section["images"] = hero_images
 | 
				
			||||||
 | 
					    gallery["hero"] = hero_section
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    save_yaml(gallery, GALLERY_YAML)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not new_images and not deleted_images:
 | 
				
			||||||
 | 
					        print("[✓] No changes to gallery.yaml (hero)")
 | 
				
			||||||
@@ -3,6 +3,7 @@ import logging
 | 
				
			|||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def render_template(template_path, context):
 | 
					def render_template(template_path, context):
 | 
				
			||||||
 | 
					    """Render html templates"""
 | 
				
			||||||
    with open(template_path, encoding="utf-8") as f:
 | 
					    with open(template_path, encoding="utf-8") as f:
 | 
				
			||||||
        content = f.read()
 | 
					        content = f.read()
 | 
				
			||||||
    for key, value in context.items():
 | 
					    for key, value in context.items():
 | 
				
			||||||
@@ -11,6 +12,7 @@ def render_template(template_path, context):
 | 
				
			|||||||
    return content
 | 
					    return content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def render_gallery_images(images):
 | 
					def render_gallery_images(images):
 | 
				
			||||||
 | 
					    """Render the photo gallery"""
 | 
				
			||||||
    html = ""
 | 
					    html = ""
 | 
				
			||||||
    for img in images:
 | 
					    for img in images:
 | 
				
			||||||
        tags = " ".join(img.get("tags", []))
 | 
					        tags = " ".join(img.get("tags", []))
 | 
				
			||||||
@@ -24,6 +26,7 @@ def render_gallery_images(images):
 | 
				
			|||||||
    return html
 | 
					    return html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_gallery_json_from_images(images, output_dir):
 | 
					def generate_gallery_json_from_images(images, output_dir):
 | 
				
			||||||
 | 
					    """Generte the hero carrousel photo list"""
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        img_list = [img["src"] for img in images]
 | 
					        img_list = [img["src"] for img in images]
 | 
				
			||||||
        output_path = output_dir / "data" / "gallery.json"
 | 
					        output_path = output_dir / "data" / "gallery.json"
 | 
				
			||||||
@@ -35,19 +38,35 @@ def generate_gallery_json_from_images(images, output_dir):
 | 
				
			|||||||
        logging.error(f"[✗] Error generating gallery JSON: {e}")
 | 
					        logging.error(f"[✗] Error generating gallery JSON: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
 | 
					def generate_robots_txt(canonical_url, allowed_paths, output_dir):
 | 
				
			||||||
 | 
					    """Generate the robot.txt"""
 | 
				
			||||||
    robots_lines = ["User-agent: *"]
 | 
					    robots_lines = ["User-agent: *"]
 | 
				
			||||||
    for path in allowed_paths:
 | 
					
 | 
				
			||||||
        robots_lines.append(f"Allow: {path}")
 | 
					    # Block everything by default
 | 
				
			||||||
    robots_lines.append("Disallow: /")
 | 
					    robots_lines.append("Disallow: /")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Explicitly allow certain paths
 | 
				
			||||||
 | 
					    for path in allowed_paths:
 | 
				
			||||||
 | 
					        if not path.startswith("/"):
 | 
				
			||||||
 | 
					            path = "/" + path
 | 
				
			||||||
 | 
					        robots_lines.append(f"Allow: {path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    robots_lines.append("")
 | 
					    robots_lines.append("")
 | 
				
			||||||
    robots_lines.append(f"Sitemap: {canonical_url}/sitemap.xml")
 | 
					    robots_lines.append(f"Sitemap: {canonical_url.rstrip('/')}/sitemap.xml")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    content = "\n".join(robots_lines)
 | 
					    content = "\n".join(robots_lines)
 | 
				
			||||||
    output_path = output_dir / "robots.txt"
 | 
					    output_path = Path(output_dir) / "robots.txt"
 | 
				
			||||||
    with open(output_path, "w", encoding="utf-8") as f:
 | 
					
 | 
				
			||||||
        f.write(content)
 | 
					    try:
 | 
				
			||||||
    logging.info(f"[✓] robots.txt generated at {output_path}")
 | 
					        output_path.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        with open(output_path, "w", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					            f.write(content)
 | 
				
			||||||
 | 
					        logging.info(f"[✓] robots.txt generated at {output_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logging.error(f"[✗] Failed to write robots.txt: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
 | 
					def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
 | 
				
			||||||
 | 
					    """Generate the sitemap"""
 | 
				
			||||||
    urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
 | 
					    urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
 | 
				
			||||||
    urlset_end = '</urlset>\n'
 | 
					    urlset_end = '</urlset>\n'
 | 
				
			||||||
    urls = ""
 | 
					    urls = ""
 | 
				
			||||||
							
								
								
									
										123
									
								
								src/py/builder/image_processor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								src/py/builder/image_processor.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
				
			|||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from PIL import Image, features
 | 
				
			||||||
 | 
					from shutil import copyfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
 | 
				
			||||||
 | 
					    """Convert an image to WebP (or JPEG fallback) and optionally resize it."""
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        if not input_path.exists():
 | 
				
			||||||
 | 
					            logging.error(f"[✗] Image file not found: {input_path}")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        img = Image.open(input_path)
 | 
				
			||||||
 | 
					        if img.mode != "RGB":
 | 
				
			||||||
 | 
					            img = img.convert("RGB")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if resize:
 | 
				
			||||||
 | 
					            width, height = img.size
 | 
				
			||||||
 | 
					            if width > max_width:
 | 
				
			||||||
 | 
					                new_height = int((max_width / width) * height)
 | 
				
			||||||
 | 
					                img = img.resize((max_width, new_height), Image.LANCZOS)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        output_path.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check WebP support, otherwise fallback to JPEG
 | 
				
			||||||
 | 
					        fmt = "WEBP" if features.check("webp") else "JPEG"
 | 
				
			||||||
 | 
					        if fmt == "JPEG":
 | 
				
			||||||
 | 
					            output_path = output_path.with_suffix(".jpg")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
 | 
				
			||||||
 | 
					        logging.info(f"[✓] Processed image: {input_path} → {output_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logging.error(f"[✗] Error processing image {input_path}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def process_images(images, resize_images, img_dir, build_dir):
 | 
				
			||||||
 | 
					    """Process a list of image references and update paths to optimized versions."""
 | 
				
			||||||
 | 
					    for img in images:
 | 
				
			||||||
 | 
					        src_path = img_dir / img["src"]
 | 
				
			||||||
 | 
					        webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
 | 
				
			||||||
 | 
					        convert_and_resize_image(src_path, webp_path, resize=resize_images)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if webp_path.exists():
 | 
				
			||||||
 | 
					            img["src"] = str(Path(img["src"]).with_suffix(".webp"))
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Fallback if WebP not created
 | 
				
			||||||
 | 
					            jpg_path = webp_path.with_suffix(".jpg")
 | 
				
			||||||
 | 
					            if jpg_path.exists():
 | 
				
			||||||
 | 
					                img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def copy_original_images(images, img_dir, build_dir):
 | 
				
			||||||
 | 
					    """Copy original image files without processing."""
 | 
				
			||||||
 | 
					    for img in images:
 | 
				
			||||||
 | 
					        src_path = img_dir / img["src"]
 | 
				
			||||||
 | 
					        dest_path = build_dir / "img" / img["src"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if not src_path.exists():
 | 
				
			||||||
 | 
					                logging.error(f"[✗] Original image not found: {src_path}")
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            dest_path.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					            copyfile(src_path, dest_path)
 | 
				
			||||||
 | 
					            logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logging.error(f"[✗] Error copying {src_path}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_favicon_path(theme_vars, theme_dir):
 | 
				
			||||||
 | 
					    """Retrieve the favicon path from theme variables, ensuring it exists."""
 | 
				
			||||||
 | 
					    fav_path = theme_vars.get("favicon", {}).get("path")
 | 
				
			||||||
 | 
					    if not fav_path:
 | 
				
			||||||
 | 
					        logging.warning("[~] No favicon path defined in theme.yaml")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    path = Path(fav_path)
 | 
				
			||||||
 | 
					    if not path.is_absolute():
 | 
				
			||||||
 | 
					        path = theme_dir / path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not path.exists():
 | 
				
			||||||
 | 
					        logging.error(f"[✗] Favicon not found: {path}")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
 | 
				
			||||||
 | 
					    """Generate multiple PNG favicons from a single source image."""
 | 
				
			||||||
 | 
					    logo_path = get_favicon_path(theme_vars, theme_dir)
 | 
				
			||||||
 | 
					    if not logo_path:
 | 
				
			||||||
 | 
					        logging.warning("[~] PNG favicons not generated.")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        output_dir.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        specs = [
 | 
				
			||||||
 | 
					            (32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
 | 
				
			||||||
 | 
					            (192, "favicon-192.png"), (196, "favicon-196.png"),
 | 
				
			||||||
 | 
					            (152, "favicon-152.png"), (180, "favicon-180.png")
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        img = Image.open(logo_path).convert("RGBA")
 | 
				
			||||||
 | 
					        for size, name in specs:
 | 
				
			||||||
 | 
					            img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        logging.info(f"[✓] PNG favicons generated in {output_dir}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logging.error(f"[✗] Error generating PNG favicons: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def generate_favicon_ico(theme_vars, theme_dir, output_path):
 | 
				
			||||||
 | 
					    """Generate a multi-size favicon.ico from a source image."""
 | 
				
			||||||
 | 
					    logo_path = get_favicon_path(theme_vars, theme_dir)
 | 
				
			||||||
 | 
					    if not logo_path:
 | 
				
			||||||
 | 
					        logging.warning("[~] favicon.ico not generated.")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        img = Image.open(logo_path).convert("RGBA")
 | 
				
			||||||
 | 
					        output_path.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
 | 
				
			||||||
 | 
					        logging.info(f"[✓] favicon.ico generated in {output_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logging.error(f"[✗] Error generating favicon.ico: {e}")
 | 
				
			||||||
							
								
								
									
										189
									
								
								src/py/builder/site_builder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/py/builder/site_builder.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,189 @@
 | 
				
			|||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from shutil import copyfile
 | 
				
			||||||
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
 | 
				
			||||||
 | 
					from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
 | 
				
			||||||
 | 
					from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
 | 
				
			||||||
 | 
					from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Configure logging to display only the messages
 | 
				
			||||||
 | 
					logging.basicConfig(level=logging.INFO, format='%(message)s')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Define key directories used throughout the script
 | 
				
			||||||
 | 
					SRC_DIR = Path.cwd()
 | 
				
			||||||
 | 
					BUILD_DIR = SRC_DIR / "output"
 | 
				
			||||||
 | 
					TEMPLATE_DIR = SRC_DIR / "src/templates"
 | 
				
			||||||
 | 
					IMG_DIR = SRC_DIR / "config/photos"
 | 
				
			||||||
 | 
					JS_DIR = SRC_DIR / "src/public/js"
 | 
				
			||||||
 | 
					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"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def build():
 | 
				
			||||||
 | 
					    build_version = "v1.3.1"
 | 
				
			||||||
 | 
					    logging.info("\n")
 | 
				
			||||||
 | 
					    logging.info("=" * 24)
 | 
				
			||||||
 | 
					    logging.info(f"🚀 Lumeex builder {build_version}")
 | 
				
			||||||
 | 
					    logging.info("=" * 24)
 | 
				
			||||||
 | 
					    logging.info("\n === Starting build === ")
 | 
				
			||||||
 | 
					    ensure_dir(BUILD_DIR)
 | 
				
			||||||
 | 
					    copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Defining build vars
 | 
				
			||||||
 | 
					    build_date = datetime.now().strftime("%Y%m%d%H%M%S")
 | 
				
			||||||
 | 
					    build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 | 
				
			||||||
 | 
					    site_vars = load_yaml(SITE_FILE)
 | 
				
			||||||
 | 
					    gallery_vars = load_yaml(GALLERY_FILE)
 | 
				
			||||||
 | 
					    build_section = site_vars.get("build", {})
 | 
				
			||||||
 | 
					    theme_name = site_vars.get("build", {}).get("theme", "default")
 | 
				
			||||||
 | 
					    theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
 | 
				
			||||||
 | 
					    fonts_dir = theme_dir / "fonts"
 | 
				
			||||||
 | 
					    theme_css_path = theme_dir / "theme.css"
 | 
				
			||||||
 | 
					    canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
 | 
				
			||||||
 | 
					    canonical_home = f"{canonical_url}/"
 | 
				
			||||||
 | 
					    canonical_legals = f"{canonical_url}/legals/"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Copying theme.css if existing
 | 
				
			||||||
 | 
					    if theme_css_path.exists():
 | 
				
			||||||
 | 
					        dest_theme_css = BUILD_DIR / "style" / "theme.css"
 | 
				
			||||||
 | 
					        dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        copyfile(theme_css_path, dest_theme_css)
 | 
				
			||||||
 | 
					        theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
 | 
				
			||||||
 | 
					        logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        theme_css = ""
 | 
				
			||||||
 | 
					        logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
 | 
				
			||||||
 | 
					    generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
 | 
				
			||||||
 | 
					    generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
 | 
				
			||||||
 | 
					    generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Converting and resizing images if enabled
 | 
				
			||||||
 | 
					    convert_images = build_section.get("convert_images", True)
 | 
				
			||||||
 | 
					    resize_images = build_section.get("resize_images", True)
 | 
				
			||||||
 | 
					    logging.info(f"[~] convert_images = {convert_images}")
 | 
				
			||||||
 | 
					    logging.info(f"[~] resize_images = {resize_images}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    hero_images = gallery_vars.get("hero", {}).get("images", [])
 | 
				
			||||||
 | 
					    gallery_images = gallery_vars.get("gallery", {}).get("images", [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if convert_images:
 | 
				
			||||||
 | 
					        process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
 | 
				
			||||||
 | 
					        process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
 | 
				
			||||||
 | 
					        copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if "hero" not in site_vars:
 | 
				
			||||||
 | 
					        site_vars["hero"] = {}  # Initialize an empty hero section
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Adding menu
 | 
				
			||||||
 | 
					    menu_html = "\n".join(
 | 
				
			||||||
 | 
					        f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
 | 
				
			||||||
 | 
					        for item in site_vars.get("menu", {}).get("items", [])
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    site_vars["hero"]["menu_items"] = menu_html
 | 
				
			||||||
 | 
					    if "footer" in site_vars:
 | 
				
			||||||
 | 
					        site_vars["footer"]["menu_items"] = menu_html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Adding Google fonts if existing
 | 
				
			||||||
 | 
					    google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
 | 
				
			||||||
 | 
					    logging.info(f"[✓] Google Fonts link generated")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Generating thumbnail
 | 
				
			||||||
 | 
					    thumbnail_path = site_vars.get("social", {}).get("thumbnail")
 | 
				
			||||||
 | 
					    if thumbnail_path:
 | 
				
			||||||
 | 
					        src_thumb = IMG_DIR / thumbnail_path
 | 
				
			||||||
 | 
					        dest_thumb_dir = BUILD_DIR / "img" / "social"
 | 
				
			||||||
 | 
					        dest_thumb_dir.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            img = Image.open(src_thumb)
 | 
				
			||||||
 | 
					            img = img.convert("RGB")
 | 
				
			||||||
 | 
					            img = img.resize((1200, 630), Image.LANCZOS)
 | 
				
			||||||
 | 
					            img.save(dest_thumb, "JPEG", quality=90)
 | 
				
			||||||
 | 
					            logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logging.error(f"[✗] Failed to process thumbnail: {e}")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logging.warning("[~] No thumbnail found in social section")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Defining head variables
 | 
				
			||||||
 | 
					    head_vars = dict(site_vars.get("info", {}))
 | 
				
			||||||
 | 
					    head_vars.update(theme_vars.get("colors", {}))
 | 
				
			||||||
 | 
					    head_vars.update(site_vars.get("social", {}))
 | 
				
			||||||
 | 
					    head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
 | 
				
			||||||
 | 
					    head_vars["google_fonts_link"] = google_fonts_link
 | 
				
			||||||
 | 
					    head_vars["font_preloads"] = "\n".join(preload_links)
 | 
				
			||||||
 | 
					    head_vars["theme_css"] = theme_css
 | 
				
			||||||
 | 
					    head_vars["build_date"] = build_date
 | 
				
			||||||
 | 
					    head_vars["canonical"] = canonical_home
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Render the home page
 | 
				
			||||||
 | 
					    head = render_template(TEMPLATE_DIR / "head.html", head_vars)
 | 
				
			||||||
 | 
					    hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
 | 
				
			||||||
 | 
					    footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
 | 
				
			||||||
 | 
					    gallery_html = render_gallery_images(gallery_images)
 | 
				
			||||||
 | 
					    gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    signature = f"<!-- Build with Lumeex {build_version} | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
 | 
				
			||||||
 | 
					    body = f"""
 | 
				
			||||||
 | 
					    <body>
 | 
				
			||||||
 | 
					        <div class="page-loader"><div class="spinner"></div></div>
 | 
				
			||||||
 | 
					        {hero}
 | 
				
			||||||
 | 
					        {gallery}
 | 
				
			||||||
 | 
					        {footer}
 | 
				
			||||||
 | 
					    </body>
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    output_file = BUILD_DIR / "index.html"
 | 
				
			||||||
 | 
					    with open(output_file, "w", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					        f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
 | 
				
			||||||
 | 
					    logging.info(f"[✓] HTML generated: {output_file}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Rendering legals page
 | 
				
			||||||
 | 
					    head_vars["canonical"] = canonical_legals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    legals_vars = site_vars.get("legals", {})
 | 
				
			||||||
 | 
					    if legals_vars:
 | 
				
			||||||
 | 
					        head = render_template(TEMPLATE_DIR / "head.html", head_vars)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ip_paragraphs = legals_vars.get("intellectual_property", [])
 | 
				
			||||||
 | 
					        paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
 | 
				
			||||||
 | 
					        legals_context = {
 | 
				
			||||||
 | 
					            "hoster_name": legals_vars.get("hoster_name", ""),
 | 
				
			||||||
 | 
					            "hoster_adress": legals_vars.get("hoster_adress", ""),
 | 
				
			||||||
 | 
					            "hoster_contact": legals_vars.get("hoster_contact", ""),
 | 
				
			||||||
 | 
					            "intellectual_property": paragraphs_html,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
 | 
				
			||||||
 | 
					        legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
 | 
				
			||||||
 | 
					        output_legals = BUILD_DIR / "legals" / "index.html"
 | 
				
			||||||
 | 
					        output_legals.parent.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        with open(output_legals, "w", encoding="utf-8") as f:
 | 
				
			||||||
 | 
					            f.write(legals_html)
 | 
				
			||||||
 | 
					        logging.info(f"[✓] Legals page generated: {output_legals}")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logging.warning("[~] No legals section found in site.yaml")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Hero carrousel generator
 | 
				
			||||||
 | 
					    if hero_images:
 | 
				
			||||||
 | 
					        generate_gallery_json_from_images(hero_images, BUILD_DIR)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logging.warning("[~] No hero images found, skipping JSON generation.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Sitemap and robot.txt generator
 | 
				
			||||||
 | 
					    site_info = site_vars.get("info", {})
 | 
				
			||||||
 | 
					    canonical_url = site_info.get("canonical", "").rstrip("/")
 | 
				
			||||||
 | 
					    if canonical_url:
 | 
				
			||||||
 | 
					        allowed_pages = ["/", "/legals/"]
 | 
				
			||||||
 | 
					        generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
 | 
				
			||||||
 | 
					        generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logging.info("✅ Build complete.")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
@@ -4,6 +4,7 @@ from pathlib import Path
 | 
				
			|||||||
from shutil import copytree, rmtree, copyfile
 | 
					from shutil import copytree, rmtree, copyfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def load_yaml(path):
 | 
					def load_yaml(path):
 | 
				
			||||||
 | 
					    """Load gallery and site .yaml conf"""
 | 
				
			||||||
    if not path.exists():
 | 
					    if not path.exists():
 | 
				
			||||||
        logging.warning(f"[!] YAML file not found: {path}")
 | 
					        logging.warning(f"[!] YAML file not found: {path}")
 | 
				
			||||||
        return {}
 | 
					        return {}
 | 
				
			||||||
@@ -11,6 +12,7 @@ def load_yaml(path):
 | 
				
			|||||||
        return yaml.safe_load(f)
 | 
					        return yaml.safe_load(f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def load_theme_config(theme_name, themes_dir):
 | 
					def load_theme_config(theme_name, themes_dir):
 | 
				
			||||||
 | 
					    """Load theme.yaml"""
 | 
				
			||||||
    theme_dir = themes_dir / theme_name
 | 
					    theme_dir = themes_dir / theme_name
 | 
				
			||||||
    theme_config_path = theme_dir / "theme.yaml"
 | 
					    theme_config_path = theme_dir / "theme.yaml"
 | 
				
			||||||
    if not theme_config_path.exists():
 | 
					    if not theme_config_path.exists():
 | 
				
			||||||
@@ -19,12 +21,26 @@ def load_theme_config(theme_name, themes_dir):
 | 
				
			|||||||
        theme_vars = yaml.safe_load(f)
 | 
					        theme_vars = yaml.safe_load(f)
 | 
				
			||||||
    return theme_vars, theme_dir
 | 
					    return theme_vars, theme_dir
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def ensure_dir(path):
 | 
					def clear_dir(path: Path):
 | 
				
			||||||
    if path.exists():
 | 
					    """Clear the output dir"""
 | 
				
			||||||
        rmtree(path)
 | 
					    if not path.exists():
 | 
				
			||||||
    path.mkdir(parents=True)
 | 
					        path.mkdir(parents=True)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    for child in path.iterdir():
 | 
				
			||||||
 | 
					        if child.is_file() or child.is_symlink():
 | 
				
			||||||
 | 
					            child.unlink()
 | 
				
			||||||
 | 
					        elif child.is_dir():
 | 
				
			||||||
 | 
					            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):
 | 
					def copy_assets(js_dir, style_dir, build_dir):
 | 
				
			||||||
 | 
					    """Copy public assets to output dir"""
 | 
				
			||||||
    for folder in [js_dir, style_dir]:
 | 
					    for folder in [js_dir, style_dir]:
 | 
				
			||||||
        if folder.exists():
 | 
					        if folder.exists():
 | 
				
			||||||
            dest = build_dir / folder.name
 | 
					            dest = build_dir / folder.name
 | 
				
			||||||
@@ -1,73 +0,0 @@
 | 
				
			|||||||
import logging
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from PIL import Image
 | 
					 | 
				
			||||||
from shutil import copyfile
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        img = Image.open(input_path)
 | 
					 | 
				
			||||||
        if img.mode != "RGB":
 | 
					 | 
				
			||||||
            img = img.convert("RGB")
 | 
					 | 
				
			||||||
        if resize:
 | 
					 | 
				
			||||||
            width, height = img.size
 | 
					 | 
				
			||||||
            if width > max_width:
 | 
					 | 
				
			||||||
                new_height = int((max_width / width) * height)
 | 
					 | 
				
			||||||
                img = img.resize((max_width, new_height), Image.LANCZOS)
 | 
					 | 
				
			||||||
        output_path.parent.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        img.save(output_path, "WEBP", quality=100)
 | 
					 | 
				
			||||||
        logging.info(f"[✓] Processed: {input_path} → {output_path}")
 | 
					 | 
				
			||||||
    except Exception as e:
 | 
					 | 
				
			||||||
        logging.error(f"[✗] Failed to process {input_path}: {e}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def process_images(images, resize_images, img_dir, build_dir):
 | 
					 | 
				
			||||||
    for img in images:
 | 
					 | 
				
			||||||
        src_path = img_dir / img["src"]
 | 
					 | 
				
			||||||
        webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
 | 
					 | 
				
			||||||
        convert_and_resize_image(src_path, webp_path, resize=resize_images)
 | 
					 | 
				
			||||||
        img["src"] = str(Path(img["src"]).with_suffix(".webp"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def copy_original_images(images, img_dir, build_dir):
 | 
					 | 
				
			||||||
    for img in images:
 | 
					 | 
				
			||||||
        src_path = img_dir / img["src"]
 | 
					 | 
				
			||||||
        dest_path = build_dir / "img" / img["src"]
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            dest_path.parent.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
            copyfile(src_path, dest_path)
 | 
					 | 
				
			||||||
            logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
 | 
					 | 
				
			||||||
        except Exception as e:
 | 
					 | 
				
			||||||
            logging.error(f"[✗] Failed to copy {src_path}: {e}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
 | 
					 | 
				
			||||||
    logo_path = get_favicon_path(theme_vars, theme_dir)
 | 
					 | 
				
			||||||
    if not logo_path:
 | 
					 | 
				
			||||||
        logging.warning("[~] No favicon path defined, skipping favicon PNGs.")
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    output_dir.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
    specs = [(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
 | 
					 | 
				
			||||||
             (192, "favicon-192.png"), (196, "favicon-196.png"), (152, "favicon-152.png"), (180, "favicon-180.png")]
 | 
					 | 
				
			||||||
    img = Image.open(logo_path).convert("RGBA")
 | 
					 | 
				
			||||||
    for size, name in specs:
 | 
					 | 
				
			||||||
        img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
 | 
					 | 
				
			||||||
    logging.info(f"[✓] Favicons generated in {output_dir}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
 | 
					 | 
				
			||||||
    logo_path = get_favicon_path(theme_vars, theme_dir)
 | 
					 | 
				
			||||||
    if not logo_path:
 | 
					 | 
				
			||||||
        logging.warning("[~] No favicon path defined, skipping .ico generation.")
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        img = Image.open(logo_path).convert("RGBA")
 | 
					 | 
				
			||||||
        output_path.parent.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
 | 
					 | 
				
			||||||
        logging.info(f"[✓] favicon.ico generated at {output_path}")
 | 
					 | 
				
			||||||
    except Exception as e:
 | 
					 | 
				
			||||||
        logging.error(f"[✗] Failed to generate favicon.ico: {e}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_favicon_path(theme_vars, theme_dir):
 | 
					 | 
				
			||||||
    fav_path = theme_vars.get("favicon", {}).get("path")
 | 
					 | 
				
			||||||
    if not fav_path:
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
    path = Path(fav_path)
 | 
					 | 
				
			||||||
    if not path.is_absolute():
 | 
					 | 
				
			||||||
        path = theme_dir / path
 | 
					 | 
				
			||||||
    return path if path.exists() else None
 | 
					 | 
				
			||||||
							
								
								
									
										0
									
								
								src/py/webui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/py/webui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										65
									
								
								src/py/webui/upload.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/py/webui/upload.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					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/<section>/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
 | 
				
			||||||
							
								
								
									
										107
									
								
								src/py/webui/webui.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/py/webui/webui.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
				
			|||||||
 | 
					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, 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 ---
 | 
				
			||||||
 | 
					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=""
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Photos directory (configurable) ---
 | 
				
			||||||
 | 
					PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
 | 
				
			||||||
 | 
					app.config["PHOTOS_DIR"] = PHOTOS_DIR
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Register upload blueprint ---
 | 
				
			||||||
 | 
					app.register_blueprint(upload_bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Routes ---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/")
 | 
				
			||||||
 | 
					def index():
 | 
				
			||||||
 | 
					    """Serve the main HTML page."""
 | 
				
			||||||
 | 
					    return render_template("index.html")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/api/gallery", methods=["GET"])
 | 
				
			||||||
 | 
					def get_gallery():
 | 
				
			||||||
 | 
					    """Return JSON list of gallery images from YAML."""
 | 
				
			||||||
 | 
					    data = load_yaml(GALLERY_YAML)
 | 
				
			||||||
 | 
					    return jsonify(data.get("gallery", {}).get("images", []))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/api/hero", methods=["GET"])
 | 
				
			||||||
 | 
					def get_hero():
 | 
				
			||||||
 | 
					    """Return JSON list of hero images from YAML."""
 | 
				
			||||||
 | 
					    data = load_yaml(GALLERY_YAML)
 | 
				
			||||||
 | 
					    return jsonify(data.get("hero", {}).get("images", []))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/api/gallery/update", methods=["POST"])
 | 
				
			||||||
 | 
					def update_gallery_api():
 | 
				
			||||||
 | 
					    """Update gallery images in YAML from frontend JSON."""
 | 
				
			||||||
 | 
					    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 in YAML from frontend JSON."""
 | 
				
			||||||
 | 
					    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 YAML from photos/gallery folder."""
 | 
				
			||||||
 | 
					    update_gallery()
 | 
				
			||||||
 | 
					    return jsonify({"status": "ok"})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/api/hero/refresh", methods=["POST"])
 | 
				
			||||||
 | 
					def refresh_hero():
 | 
				
			||||||
 | 
					    """Refresh hero YAML from photos/hero folder."""
 | 
				
			||||||
 | 
					    update_hero()
 | 
				
			||||||
 | 
					    return jsonify({"status": "ok"})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/api/gallery/delete", methods=["POST"])
 | 
				
			||||||
 | 
					def delete_gallery_photo():
 | 
				
			||||||
 | 
					    """Delete a gallery photo from disk and return status."""
 | 
				
			||||||
 | 
					    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 from disk and return status."""
 | 
				
			||||||
 | 
					    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("/photos/<section>/<path:filename>")
 | 
				
			||||||
 | 
					def photos(section, filename):
 | 
				
			||||||
 | 
					    """Serve uploaded photos from disk."""
 | 
				
			||||||
 | 
					    return send_from_directory(PHOTOS_DIR / section, filename)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Run server ---
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    logging.info("Starting WebUI at http://127.0.0.1:5000")
 | 
				
			||||||
 | 
					    app.run(debug=True)
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								src/webui/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/webui/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 7.0 KiB  | 
							
								
								
									
										166
									
								
								src/webui/img/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/webui/img/logo.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,166 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6031 1000">
 | 
				
			||||||
 | 
					  <!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8)  -->
 | 
				
			||||||
 | 
					  <defs>
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					      .st0 {
 | 
				
			||||||
 | 
					        fill: #55c3ec;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st1 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_265);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_33);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
 | 
				
			||||||
 | 
					        stroke-miterlimit: 10;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st2 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_269);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_334);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st3 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_268);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_333);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st4 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_266);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_331);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st5 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_267);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_332);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st13 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_261);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st14 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_262);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st15 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_264);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st16 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_263);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st6 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2616);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_3311);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st7 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2615);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_3310);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st17 {
 | 
				
			||||||
 | 
					        fill: #fff;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st18 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_26);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st8 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2610);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_335);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st9 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2613);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_338);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st10 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2614);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_339);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st11 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2611);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_336);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .st12 {
 | 
				
			||||||
 | 
					        fill: url(#Dégradé_sans_nom_2612);
 | 
				
			||||||
 | 
					        stroke: url(#Dégradé_sans_nom_337);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
 | 
				
			||||||
 | 
					      <stop offset="0" stop-color="#55c3ec"/>
 | 
				
			||||||
 | 
					      <stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
 | 
				
			||||||
 | 
					    </linearGradient>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
 | 
				
			||||||
 | 
					      <stop offset="0" stop-color="#55c3ec"/>
 | 
				
			||||||
 | 
					      <stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
 | 
				
			||||||
 | 
					    </linearGradient>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
 | 
				
			||||||
 | 
					    <linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
 | 
				
			||||||
 | 
					  </defs>
 | 
				
			||||||
 | 
					  <g id="Calque_1">
 | 
				
			||||||
 | 
					    <circle class="st17" cx="499.5" cy="499.5" r="499.5"/>
 | 
				
			||||||
 | 
					    <g>
 | 
				
			||||||
 | 
					      <path class="st17" d="M1404,957.4V45h191v755h399v157.4h-590Z"/>
 | 
				
			||||||
 | 
					      <path class="st17" d="M2321.5,971.3c-49.3,0-91.5-10.2-126.5-30.7-35-20.4-61.6-49.6-79.7-87.6-18.1-37.9-27.2-83.2-27.2-135.9v-437.6h184.6v399c0,44.3,10.4,78.6,31.3,103.1,20.9,24.5,51.9,36.7,93.3,36.7s39.3-3.6,56-10.7c16.6-7.2,30.9-17.4,42.7-30.7,11.8-13.3,20.9-29.1,27.2-47.4s9.5-38.5,9.5-60.4v-389.5h184.6v677.8h-184.6v-111.9h-4.4c-11.4,25.7-26.7,48.1-45.8,67-19.2,19-42.2,33.5-68.9,43.6-26.8,10.1-57.4,15.2-92,15.2Z"/>
 | 
				
			||||||
 | 
					      <path class="st17" d="M2837.5,957.4V279.6h184.6v113.8h3.8c13.9-38.8,37.6-69.8,71.1-93,33.5-23.2,73-34.8,118.6-34.8s60.1,5.5,85.4,16.4c25.3,11,46.7,26.8,64.2,47.4,17.5,20.7,29.8,46,37,75.9h3.8c10.1-28.7,25.4-53.4,45.8-74.3,20.4-20.9,44.7-37,72.7-48.4,28-11.4,58.7-17.1,92-17.1s83,9.5,116.3,28.5c33.3,19,59.2,45.5,77.8,79.7,18.5,34.1,27.8,74.2,27.8,120.1v463.5h-184.6v-416.7c0-26.6-4.3-48.8-13-66.7-8.6-17.9-21.1-31.6-37.3-41.1-16.2-9.5-36.4-14.2-60.4-14.2s-43.5,5.4-61,16.1c-17.5,10.7-31.1,25.6-40.8,44.6-9.7,19-14.5,41.1-14.5,66.4v411.6h-177.7v-422.4c0-24.4-4.4-45.3-13.3-62.6-8.9-17.3-21.4-30.6-37.6-39.8-16.2-9.3-35.7-13.9-58.5-13.9s-43.6,5.6-61.3,16.8c-17.7,11.2-31.5,26.5-41.4,45.8-9.9,19.4-14.9,41.7-14.9,67v409.1h-184.6Z"/>
 | 
				
			||||||
 | 
					      <path class="st0" d="M4264,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6-36,12.6-78.3,19-126.8,19Z"/>
 | 
				
			||||||
 | 
					      <path class="st0" d="M4982.9,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6s-78.3,19-126.8,19Z"/>
 | 
				
			||||||
 | 
					      <path class="st0" d="M5337,957.4l212.5-337.7-210.6-340.2h208l122,228.9h3.8l120.1-228.9h201.1l-211.8,335.1,209.9,342.7h-200.4l-129-234.6h-3.8l-127.1,234.6h-194.8Z"/>
 | 
				
			||||||
 | 
					    </g>
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					  <g id="Calque_2">
 | 
				
			||||||
 | 
					    <g id="Calque_3">
 | 
				
			||||||
 | 
					      <ellipse class="st18" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
 | 
				
			||||||
 | 
					      <ellipse class="st13" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
 | 
				
			||||||
 | 
					      <ellipse class="st14" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
 | 
				
			||||||
 | 
					      <ellipse class="st16" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
 | 
				
			||||||
 | 
					      <ellipse class="st15" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
 | 
				
			||||||
 | 
					      <circle class="st1" cx="90.4" cy="576" r="22.4"/>
 | 
				
			||||||
 | 
					      <circle class="st4" cx="175.6" cy="607.9" r="13.1"/>
 | 
				
			||||||
 | 
					      <circle class="st5" cx="140.8" cy="691.6" r="28"/>
 | 
				
			||||||
 | 
					      <circle class="st3" cx="829.7" cy="602.6" r="28"/>
 | 
				
			||||||
 | 
					      <circle class="st2" cx="908.9" cy="562.1" r="13.1"/>
 | 
				
			||||||
 | 
					      <circle class="st8" cx="840.9" cy="698.1" r="22.4"/>
 | 
				
			||||||
 | 
					      <circle class="st11" cx="466.1" cy="876.5" r="22.5"/>
 | 
				
			||||||
 | 
					      <circle class="st12" cx="538.6" cy="839.8" r="13.1"/>
 | 
				
			||||||
 | 
					      <circle class="st9" cx="686.1" cy="170.1" r="28"/>
 | 
				
			||||||
 | 
					      <circle class="st10" cx="733.7" cy="247.7" r="13.1"/>
 | 
				
			||||||
 | 
					      <circle class="st7" cx="236.9" cy="206.5" r="21.1"/>
 | 
				
			||||||
 | 
					      <circle class="st6" cx="315.4" cy="164.9" r="13.1"/>
 | 
				
			||||||
 | 
					    </g>
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 13 KiB  | 
							
								
								
									
										91
									
								
								src/webui/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/webui/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					  <meta charset="UTF-8">
 | 
				
			||||||
 | 
					  <title>Photo WebUI</title>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- Link to your CSS in the package -->
 | 
				
			||||||
 | 
					  <link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					  <!-- Top bar -->	
 | 
				
			||||||
 | 
							<div class="nav-bar">
 | 
				
			||||||
 | 
								<div class="content-inner nav">
 | 
				
			||||||
 | 
									<div class="nav-cta">
 | 
				
			||||||
 | 
									  	<div class="arrow">
 | 
				
			||||||
 | 
					             →
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					              <a class="button" href="#" target="_blank"><span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span></a>
 | 
				
			||||||
 | 
									  </div>
 | 
				
			||||||
 | 
								  <input type="checkbox" id="nav-check">
 | 
				
			||||||
 | 
								  <div class="nav-header">
 | 
				
			||||||
 | 
								    <div class="nav-title">
 | 
				
			||||||
 | 
								      <img src="{{ url_for('static', filename='img/logo.svg') }}">
 | 
				
			||||||
 | 
								    </div>
 | 
				
			||||||
 | 
								  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								  <div class="nav-btn">
 | 
				
			||||||
 | 
								  	<label for="nav-check">
 | 
				
			||||||
 | 
					      			<span></span>
 | 
				
			||||||
 | 
					     				<span></span>
 | 
				
			||||||
 | 
					     				<span></span>
 | 
				
			||||||
 | 
					    			</label>
 | 
				
			||||||
 | 
								  </div>
 | 
				
			||||||
 | 
								  
 | 
				
			||||||
 | 
								  <div class="nav-links">
 | 
				
			||||||
 | 
								  	<ul class="nav-list">
 | 
				
			||||||
 | 
									    <li class="nav-item appear2"><a href="#">Site info</a>
 | 
				
			||||||
 | 
									    <li class="nav-item appear2"><a href="#qui-suis-je">Theme info</a>
 | 
				
			||||||
 | 
									    <li class="nav-item appear2"><a href="#mariages">Gallery</a>
 | 
				
			||||||
 | 
									  </ul>
 | 
				
			||||||
 | 
									  
 | 
				
			||||||
 | 
								  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							  </div>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
					  <!-- Toast container for notifications -->
 | 
				
			||||||
 | 
					  <div class="content-inner">
 | 
				
			||||||
 | 
					    <div id="toast-container"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <h1>Gallery editor</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Hero Upload Section -->
 | 
				
			||||||
 | 
					    <div class="upload-section">
 | 
				
			||||||
 | 
					      <h2>Title Carrousel</h2>
 | 
				
			||||||
 | 
					      <p> Select photos to display in the Title Carrousel</p>
 | 
				
			||||||
 | 
					      <label for="upload-hero" class="custom-upload-btn">
 | 
				
			||||||
 | 
					        📸 Upload photos
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					      <input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
 | 
				
			||||||
 | 
					      <div id="hero"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Gallery Upload Section -->
 | 
				
			||||||
 | 
					    <div class="upload-section">
 | 
				
			||||||
 | 
					      <h2>Gallery</h2>
 | 
				
			||||||
 | 
					      <p> Select and tags photos to display in the Gallery</p>
 | 
				
			||||||
 | 
					      <label for="upload-gallery" class="custom-upload-btn">
 | 
				
			||||||
 | 
					        📸 Upload photos
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					      <input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
 | 
				
			||||||
 | 
					      <div id="gallery"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- JS files for rendering, uploading, and actions -->
 | 
				
			||||||
 | 
					    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
 | 
				
			||||||
 | 
					    <script src="{{ url_for('static', filename='js/upload.js') }}"></script>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <!-- Delete confirmation modal -->
 | 
				
			||||||
 | 
					  <div id="delete-modal" class="modal" style="display:none;">
 | 
				
			||||||
 | 
					    <div class="modal-content">
 | 
				
			||||||
 | 
					      <span id="delete-modal-close" class="modal-close">×</span>
 | 
				
			||||||
 | 
					      <h3>Confirm Deletion</h3>
 | 
				
			||||||
 | 
					      <p id="delete-modal-text">Are you sure you want to delete this image?</p>
 | 
				
			||||||
 | 
					      <div class="modal-actions">
 | 
				
			||||||
 | 
					        <button id="delete-modal-confirm" class="modal-btn danger">Delete</button>
 | 
				
			||||||
 | 
					        <button id="delete-modal-cancel" class="modal-btn">Cancel</button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										362
									
								
								src/webui/js/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										362
									
								
								src/webui/js/main.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,362 @@
 | 
				
			|||||||
 | 
					// --- 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 = `
 | 
				
			||||||
 | 
					      <div class="flex-item">
 | 
				
			||||||
 | 
					        <img src="/photos/${img.src}">
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="tags-display" data-index="${i}"></div>
 | 
				
			||||||
 | 
					      <div class="flex-item flex-full">
 | 
				
			||||||
 | 
					      <div class="flex-item flex-end">
 | 
				
			||||||
 | 
					          <button onclick="showDeleteModal('gallery', ${i})">🗑 Delete</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="tag-input" data-index="${i}"></div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					    container.appendChild(div);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    renderTags(i, img.tags || []);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- 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}"]`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // vider
 | 
				
			||||||
 | 
					  tagsDisplay.innerHTML = '';
 | 
				
			||||||
 | 
					  inputContainer.innerHTML = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // --- rendre les tags (en haut) ---
 | 
				
			||||||
 | 
					  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);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // --- input (en bas) ---
 | 
				
			||||||
 | 
					  const input = document.createElement('input');
 | 
				
			||||||
 | 
					  input.type = 'text';
 | 
				
			||||||
 | 
					  input.placeholder = 'Add tag...';
 | 
				
			||||||
 | 
					  inputContainer.appendChild(input);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // suggestion box
 | 
				
			||||||
 | 
					  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 = `<b>${s.substring(0, input.value.length)}</b>`;
 | 
				
			||||||
 | 
					        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);
 | 
				
			||||||
 | 
					  input.addEventListener('focus', updateSuggestions);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					    } else if ([' ', ','].includes(e.key)) {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					      addTag(input.value);
 | 
				
			||||||
 | 
					      input.value = '';
 | 
				
			||||||
 | 
					      updateSuggestions();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  input.addEventListener('blur', () => {
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					      suggestionBox.style.display = 'none';
 | 
				
			||||||
 | 
					      input.value = '';
 | 
				
			||||||
 | 
					    }, 150);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 = `
 | 
				
			||||||
 | 
					      <div class="flex-item">
 | 
				
			||||||
 | 
					        <img src="/photos/${img.src}">
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex-item flex-full">
 | 
				
			||||||
 | 
					        <div class="flex-item flex-end">
 | 
				
			||||||
 | 
					        <button onclick="showDeleteModal('hero', ${i})">🗑 Delete</button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					    container.appendChild(div);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- 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', index: number }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- Show delete confirmation modal ---
 | 
				
			||||||
 | 
					function showDeleteModal(type, index) {
 | 
				
			||||||
 | 
					  pendingDelete = { type, index };
 | 
				
			||||||
 | 
					  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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  hideDeleteModal();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- Modal event listeners ---
 | 
				
			||||||
 | 
					document.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-close').onclick = hideDeleteModal;
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-confirm').onclick = confirmDelete;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- 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");
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- Modal event listeners ---
 | 
				
			||||||
 | 
					document.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-close').onclick = hideDeleteModal;
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
 | 
				
			||||||
 | 
					  document.getElementById('delete-modal-confirm').onclick = confirmDelete;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- Initialize ---
 | 
				
			||||||
 | 
					loadData();
 | 
				
			||||||
							
								
								
									
										41
									
								
								src/webui/js/upload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/webui/js/upload.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
				
			|||||||
 | 
					// --- Upload gallery images ---
 | 
				
			||||||
 | 
					document.getElementById('upload-gallery').addEventListener('change', async (e) => {
 | 
				
			||||||
 | 
					  const files = e.target.files;
 | 
				
			||||||
 | 
					  if (!files.length) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					    if (res.ok) {
 | 
				
			||||||
 | 
					      showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
 | 
				
			||||||
 | 
					      refreshGallery();
 | 
				
			||||||
 | 
					    } else showToast('Error: ' + data.error, "error");
 | 
				
			||||||
 | 
					  } catch(err) {
 | 
				
			||||||
 | 
					    console.error(err);
 | 
				
			||||||
 | 
					    showToast('Server error!', "error");
 | 
				
			||||||
 | 
					  } finally { e.target.value = ''; }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --- Upload hero images ---
 | 
				
			||||||
 | 
					document.getElementById('upload-hero').addEventListener('change', async (e) => {
 | 
				
			||||||
 | 
					  const files = e.target.files;
 | 
				
			||||||
 | 
					  if (!files.length) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					    if (res.ok) {
 | 
				
			||||||
 | 
					      showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
 | 
				
			||||||
 | 
					      refreshHero();
 | 
				
			||||||
 | 
					    } else showToast('Error: ' + data.error, "error");
 | 
				
			||||||
 | 
					  } catch(err) {
 | 
				
			||||||
 | 
					    console.error(err);
 | 
				
			||||||
 | 
					    showToast('Server error!', "error");
 | 
				
			||||||
 | 
					  } finally { e.target.value = ''; }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										428
									
								
								src/webui/style/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										428
									
								
								src/webui/style/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,428 @@
 | 
				
			|||||||
 | 
					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;
 | 
				
			||||||
 | 
					  margin: 20px;
 | 
				
			||||||
 | 
					  background:radial-gradient(ellipse at bottom center, #002a30, #000000bd), radial-gradient(ellipse at top center, #0558a8, #000000fa);
 | 
				
			||||||
 | 
					  color: #FBFBFB;
 | 
				
			||||||
 | 
					  min-height: 100vh;
 | 
				
			||||||
 | 
					  margin:0px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-inner {
 | 
				
			||||||
 | 
					  max-width: 90%;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					h1, h2 {
 | 
				
			||||||
 | 
					  color: #FBFBFB;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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;
 | 
				
			||||||
 | 
					  margin-top: 30px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Responsive adjustments */
 | 
				
			||||||
 | 
					@media (max-width: 500px) {
 | 
				
			||||||
 | 
					  body {
 | 
				
			||||||
 | 
					    margin: 10px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .toolbar button {
 | 
				
			||||||
 | 
					    margin-bottom: 8px;
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .upload-section label {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    margin-bottom: 10px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Toast notifications */
 | 
				
			||||||
 | 
					#toast-container {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 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: 0.5rem;
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast.show {
 | 
				
			||||||
 | 
					  opacity: 1;
 | 
				
			||||||
 | 
					  transform: translateY(0);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast.success { background-color: #28a745; }
 | 
				
			||||||
 | 
					.toast.error { background-color: #dc3545; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Tags */
 | 
				
			||||||
 | 
					.tag-input {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-wrap: wrap-reverse;
 | 
				
			||||||
 | 
					    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; /* ensure it displays above other elements */
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					  box-shadow: 0 4px 8px rgba(0,0,0,0.15); /* subtle shadow for visibility */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.suggestions li.selected {
 | 
				
			||||||
 | 
					  background-color: #007782;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.suggestions li {
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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%
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Top bar */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav {
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-bar {
 | 
				
			||||||
 | 
					  height: 70px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  background-color: #0c0d0c29;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  z-index: 1;
 | 
				
			||||||
 | 
					  backdrop-filter:  blur(20px);
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #21212157;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav img {
 | 
				
			||||||
 | 
						height: 30px;
 | 
				
			||||||
 | 
					  padding: 0px;
 | 
				
			||||||
 | 
					  margin-top: 10px;
 | 
				
			||||||
 | 
					} 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-header {
 | 
				
			||||||
 | 
					  display: inline;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-header > .nav-title {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  font-size: 22px;
 | 
				
			||||||
 | 
					  color: #fff;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					  margin-top: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-btn {
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-links {
 | 
				
			||||||
 | 
					  display: inline;
 | 
				
			||||||
 | 
					  float: right;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  line-height: 70px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-item {
 | 
				
			||||||
 | 
						display: inline;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-list {
 | 
				
			||||||
 | 
						 list-style-type: disc;
 | 
				
			||||||
 | 
						 margin: 0px;
 | 
				
			||||||
 | 
						 padding: 0px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-links > .nav-list > .nav-item > a {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  padding: 0px 15px 0px 15px;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  font-weight: 700;
 | 
				
			||||||
 | 
					  color:#fff
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > .nav-links > .nav-list > .nav-item > a:hover {
 | 
				
			||||||
 | 
					  color: #00b0f0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav > #nav-check {
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-list > li + li::before{
 | 
				
			||||||
 | 
					  content: " → ";
 | 
				
			||||||
 | 
					  color: #ffc700;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-cta {
 | 
				
			||||||
 | 
						  display: inline;
 | 
				
			||||||
 | 
					    float: right;
 | 
				
			||||||
 | 
					    height: 70px;
 | 
				
			||||||
 | 
					    line-height: 70px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-cta > .arrow {
 | 
				
			||||||
 | 
						font-size: 12px;
 | 
				
			||||||
 | 
						display: inline;
 | 
				
			||||||
 | 
					  color: #ffc700;
 | 
				
			||||||
 | 
					  font-weight: 700;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-cta > .button {
 | 
				
			||||||
 | 
						padding: 10px 25px;
 | 
				
			||||||
 | 
						border-radius: 40px;
 | 
				
			||||||
 | 
						margin: 15px 20px 15px 10px;
 | 
				
			||||||
 | 
						font-size: 12px;
 | 
				
			||||||
 | 
						display: inline;
 | 
				
			||||||
 | 
					  background: linear-gradient(135deg, #26c4ff, #016074);
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  color: #fff;
 | 
				
			||||||
 | 
					  font-weight: 700;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-cta > .button:hover {
 | 
				
			||||||
 | 
						    background: linear-gradient(135deg, #72d9ff, #26657e);
 | 
				
			||||||
 | 
					      transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-links > ul {
 | 
				
			||||||
 | 
						display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Custom upload buttons */
 | 
				
			||||||
 | 
					.custom-upload-btn {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  background: #09A0C1;
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					  box-shadow: 0 4px 10px rgba(0,0,0,0.25);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.custom-upload-btn:hover {
 | 
				
			||||||
 | 
					  background: #55c3ec;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Modal styles */
 | 
				
			||||||
 | 
					.modal {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 0; left: 0; right: 0; bottom: 0;
 | 
				
			||||||
 | 
					  z-index: 10000;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.modal-content {
 | 
				
			||||||
 | 
					  background: #ffffff29;
 | 
				
			||||||
 | 
					  color: #fff;
 | 
				
			||||||
 | 
					  padding: 2rem 2.5rem;
 | 
				
			||||||
 | 
					  border-radius: 10px;
 | 
				
			||||||
 | 
					  box-shadow: 0 4px 24px rgba(0,0,0,0.25);
 | 
				
			||||||
 | 
					  min-width: 300px;
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user