Compare commits
	
		
			21 Commits
		
	
	
		
			v1.3.1
			...
			2ec4be624b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					2ec4be624b | ||
| 
						 | 
					369704a87c | ||
| 
						 | 
					330e467dcb | ||
| 
						 | 
					305042b365 | ||
| 
						 | 
					7a7876e5ef | ||
| 
						 | 
					8cb81a74cf | ||
| 
						 | 
					c193fd49aa | ||
| 
						 | 
					5d863223e3 | ||
| 
						 | 
					a8f3c1b497 | ||
| 
						 | 
					b74f1bb350 | ||
| 
						 | 
					5a6f08644a | ||
| 
						 | 
					031ff62168 | ||
| 
						 | 
					b56d03303e | ||
| 
						 | 
					d3484a4b50 | ||
| 
						 | 
					9d37b0a60f | ||
| 
						 | 
					080eb2593d | ||
| 
						 | 
					73a0dd0ce6 | ||
| 
						 | 
					97645b06fa | ||
| 
						 | 
					142c042b86 | ||
| 
						 | 
					041db66b3d | ||
| 
						 | 
					1b0b228273 | 
							
								
								
									
										2
									
								
								build.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								build.py
									
									
									
									
									
								
							@@ -1,5 +1,5 @@
 | 
			
		||||
import logging
 | 
			
		||||
from src.py.site_builder import build
 | 
			
		||||
from src.py.builder.site_builder import build
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    logging.basicConfig(level=logging.INFO, format="%(message)s")
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,8 @@ footer:
 | 
			
		||||
# Build parameters
 | 
			
		||||
build:
 | 
			
		||||
  theme: modern # choose a theme in config/theme folder
 | 
			
		||||
  convert_images: true # true to enable image conversion
 | 
			
		||||
  resize_images: true # true to enable image resizing
 | 
			
		||||
 | 
			
		||||
# Change this by your legals
 | 
			
		||||
legals:
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ gallery:
 | 
			
		||||
  - src: gallery/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
 | 
			
		||||
    tags: [landscape, sky, cloud, mountains]
 | 
			
		||||
  - src: gallery/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
 | 
			
		||||
    tags: [lanscape, sunset, mountains]
 | 
			
		||||
    tags: [landscape, sunset, mountains]
 | 
			
		||||
  - src: gallery/jonas-degener-LueP5EdWGFY-unsplash.jpg
 | 
			
		||||
    tags: [landscape, mountains, fog]
 | 
			
		||||
  - src: gallery/michiel-annaert-M27pZnHV6M0-unsplash.jpg
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import logging
 | 
			
		||||
from src.py.gallery_builder import update_gallery, update_hero
 | 
			
		||||
from src.py.builder.gallery_builder import update_gallery, update_hero
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    logging.basicConfig(level=logging.INFO, format="%(message)s")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +1,3 @@
 | 
			
		||||
pyyaml
 | 
			
		||||
pillow
 | 
			
		||||
flask
 | 
			
		||||
@@ -69,7 +69,8 @@ const setupTagFilter = () => {
 | 
			
		||||
  const allSections = document.querySelectorAll('.section[data-tags]');
 | 
			
		||||
  const allTags = document.querySelectorAll('.tag');
 | 
			
		||||
  let activeTags = [];
 | 
			
		||||
  let lastClickedTag = null; // mémorise le dernier tag cliqué
 | 
			
		||||
  let lastClickedTag = null; // remembers the last clicked tag
 | 
			
		||||
  let lastClickedSection = null; // remembers the last clicked section (photo)
 | 
			
		||||
 | 
			
		||||
  const applyFilter = () => {
 | 
			
		||||
    let filteredSections = [];
 | 
			
		||||
@@ -81,30 +82,41 @@ const setupTagFilter = () => {
 | 
			
		||||
      section.style.display = hasAllTags ? '' : 'none';
 | 
			
		||||
 | 
			
		||||
      if (hasAllTags) {
 | 
			
		||||
        filteredSections.push(section);
 | 
			
		||||
        if (lastClickedTag && sectionTags.includes(lastClickedTag) && !matchingSection) {
 | 
			
		||||
        if (lastClickedSection === section) {
 | 
			
		||||
          matchingSection = section;
 | 
			
		||||
        } else {
 | 
			
		||||
          filteredSections.push(section);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Réorganise : la photo correspondante au dernier tag cliqué en premier
 | 
			
		||||
    if (matchingSection && galleryContainer.contains(matchingSection)) {
 | 
			
		||||
      galleryContainer.prepend(matchingSection);
 | 
			
		||||
    // Remove all filtered sections from DOM before reordering
 | 
			
		||||
    if (galleryContainer) {
 | 
			
		||||
      [matchingSection, ...filteredSections].forEach(section => {
 | 
			
		||||
        if (section && galleryContainer.contains(section)) {
 | 
			
		||||
          galleryContainer.removeChild(section);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      if (matchingSection) {
 | 
			
		||||
        galleryContainer.prepend(matchingSection);
 | 
			
		||||
      }
 | 
			
		||||
      filteredSections.forEach(section => {
 | 
			
		||||
        galleryContainer.appendChild(section);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Met à jour le style des tags
 | 
			
		||||
    // Update tag styles
 | 
			
		||||
    allTags.forEach((tagEl) => {
 | 
			
		||||
      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
			
		||||
      tagEl.classList.toggle('active', activeTags.includes(tagText));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Met à jour l'URL
 | 
			
		||||
    // Update the URL
 | 
			
		||||
    const base = window.location.pathname;
 | 
			
		||||
    const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
 | 
			
		||||
    window.history.pushState({}, '', base + query);
 | 
			
		||||
 | 
			
		||||
    // Scroll jusqu'à la galerie
 | 
			
		||||
    // Scroll to the gallery
 | 
			
		||||
    if (galleryContainer) {
 | 
			
		||||
      galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
 | 
			
		||||
    }
 | 
			
		||||
@@ -113,7 +125,8 @@ const setupTagFilter = () => {
 | 
			
		||||
  allTags.forEach((tagEl) => {
 | 
			
		||||
    tagEl.addEventListener('click', () => {
 | 
			
		||||
      const tagText = tagEl.textContent.replace('#', '').toLowerCase();
 | 
			
		||||
      lastClickedTag = tagText; // mémorise le dernier tag cliqué
 | 
			
		||||
      lastClickedTag = tagText; // remembers the last clicked tag
 | 
			
		||||
      lastClickedSection = tagEl.closest('.section'); // remembers the last clicked section
 | 
			
		||||
 | 
			
		||||
      if (activeTags.includes(tagText)) {
 | 
			
		||||
        activeTags = activeTags.filter((t) => t !== tagText);
 | 
			
		||||
@@ -130,6 +143,7 @@ const setupTagFilter = () => {
 | 
			
		||||
    if (urlTags) {
 | 
			
		||||
      activeTags = urlTags.split(',').map((t) => t.toLowerCase());
 | 
			
		||||
      lastClickedTag = activeTags[activeTags.length - 1] || null;
 | 
			
		||||
      lastClickedSection = null; // No section selected from URL
 | 
			
		||||
      applyFilter();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
@@ -141,12 +155,14 @@ const disableRightClickAndDrag = () => {
 | 
			
		||||
  document.addEventListener('dragstart', (e) => e.preventDefault());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Scroll to top button
 | 
			
		||||
// Scroll-to-top button functionality
 | 
			
		||||
const setupScrollToTopButton = () => {
 | 
			
		||||
  const scrollToTopButton = document.querySelector('.scroll-to-top');
 | 
			
		||||
  if (!scrollToTopButton) return;
 | 
			
		||||
  scrollToTopButton.addEventListener('click', () => {
 | 
			
		||||
    window.scrollTo({ top: 0, behavior: 'smooth' });
 | 
			
		||||
  const scrollBtn = document.getElementById("scrollToTop");
 | 
			
		||||
  window.addEventListener("scroll", () => {
 | 
			
		||||
    scrollBtn.style.display = window.scrollY > 300 ? "block" : "none";
 | 
			
		||||
  });
 | 
			
		||||
  scrollBtn.addEventListener("click", () => {
 | 
			
		||||
    window.scrollTo({ top: 0, behavior: "smooth" });
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ from pathlib import Path
 | 
			
		||||
from shutil import copyfile
 | 
			
		||||
 | 
			
		||||
def generate_css_variables(colors_dict, output_path):
 | 
			
		||||
    """Generate css variables for theme colors"""
 | 
			
		||||
    css_lines = [":root {"]
 | 
			
		||||
    for key, value in colors_dict.items():
 | 
			
		||||
        css_lines.append(f"  --color-{key.replace('_', '-')}: {value};")
 | 
			
		||||
@@ -13,6 +14,7 @@ def generate_css_variables(colors_dict, output_path):
 | 
			
		||||
    logging.info(f"[✓] CSS variables written to {output_path}")
 | 
			
		||||
 | 
			
		||||
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
 | 
			
		||||
    """Generate css variables fonts"""
 | 
			
		||||
    font_files = list(fonts_dir.glob("*"))
 | 
			
		||||
    font_faces = {}
 | 
			
		||||
    preload_links = []
 | 
			
		||||
@@ -57,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
 | 
			
		||||
    return preload_links
 | 
			
		||||
 | 
			
		||||
def generate_google_fonts_link(fonts):
 | 
			
		||||
    """Generate src link for Google fonts"""
 | 
			
		||||
    if not fonts:
 | 
			
		||||
        return ""
 | 
			
		||||
    families = []
 | 
			
		||||
@@ -10,6 +10,7 @@ GALLERY_DIR = Path("config/photos/gallery")
 | 
			
		||||
HERO_DIR = Path("config/photos/hero")
 | 
			
		||||
 | 
			
		||||
def load_yaml(path):
 | 
			
		||||
    """Load gallery config .yaml file"""
 | 
			
		||||
    print(f"[→] Loading {path}...")
 | 
			
		||||
    if not os.path.exists(path):
 | 
			
		||||
        print(f"[✗] File not found: {path}")
 | 
			
		||||
@@ -21,11 +22,13 @@ def load_yaml(path):
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
def save_yaml(data, path):
 | 
			
		||||
    """Save modified gallery config .yaml file"""
 | 
			
		||||
    with open(path, "w", encoding="utf-8") as f:
 | 
			
		||||
        yaml.dump(data, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    print(f"[✓] Saved updated YAML to {path}")
 | 
			
		||||
 | 
			
		||||
def get_all_image_paths(directory):
 | 
			
		||||
    """Get the path to record for builded site"""
 | 
			
		||||
    return sorted([
 | 
			
		||||
        str(p.relative_to(directory.parent)).replace("\\", "/")
 | 
			
		||||
        for p in directory.rglob("*")
 | 
			
		||||
@@ -33,6 +36,7 @@ def get_all_image_paths(directory):
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
def update_gallery():
 | 
			
		||||
    """Update the gallery photo list"""
 | 
			
		||||
    print("\n=== Updating gallery.yaml (gallery section) ===")
 | 
			
		||||
    gallery = load_yaml(GALLERY_YAML)
 | 
			
		||||
 | 
			
		||||
@@ -71,6 +75,7 @@ def update_gallery():
 | 
			
		||||
        print("[✓] No changes to gallery.yaml (gallery)")
 | 
			
		||||
 | 
			
		||||
def update_hero():
 | 
			
		||||
    """Update the hero photo list"""
 | 
			
		||||
    print("\n=== Updating gallery.yaml (hero section) ===")
 | 
			
		||||
    gallery = load_yaml(GALLERY_YAML)
 | 
			
		||||
 | 
			
		||||
@@ -3,6 +3,7 @@ import logging
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
def render_template(template_path, context):
 | 
			
		||||
    """Render html templates"""
 | 
			
		||||
    with open(template_path, encoding="utf-8") as f:
 | 
			
		||||
        content = f.read()
 | 
			
		||||
    for key, value in context.items():
 | 
			
		||||
@@ -11,6 +12,7 @@ def render_template(template_path, context):
 | 
			
		||||
    return content
 | 
			
		||||
 | 
			
		||||
def render_gallery_images(images):
 | 
			
		||||
    """Render the photo gallery"""
 | 
			
		||||
    html = ""
 | 
			
		||||
    for img in images:
 | 
			
		||||
        tags = " ".join(img.get("tags", []))
 | 
			
		||||
@@ -24,6 +26,7 @@ def render_gallery_images(images):
 | 
			
		||||
    return html
 | 
			
		||||
 | 
			
		||||
def generate_gallery_json_from_images(images, output_dir):
 | 
			
		||||
    """Generte the hero carrousel photo list"""
 | 
			
		||||
    try:
 | 
			
		||||
        img_list = [img["src"] for img in images]
 | 
			
		||||
        output_path = output_dir / "data" / "gallery.json"
 | 
			
		||||
@@ -35,6 +38,7 @@ def generate_gallery_json_from_images(images, output_dir):
 | 
			
		||||
        logging.error(f"[✗] Error generating gallery JSON: {e}")
 | 
			
		||||
 | 
			
		||||
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
 | 
			
		||||
    """Generate the robot.txt"""
 | 
			
		||||
    robots_lines = ["User-agent: *"]
 | 
			
		||||
 | 
			
		||||
    # Block everything by default
 | 
			
		||||
@@ -62,6 +66,7 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir):
 | 
			
		||||
        logging.error(f"[✗] Failed to write robots.txt: {e}")
 | 
			
		||||
 | 
			
		||||
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
 | 
			
		||||
    """Generate the sitemap"""
 | 
			
		||||
    urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
 | 
			
		||||
    urlset_end = '</urlset>\n'
 | 
			
		||||
    urls = ""
 | 
			
		||||
@@ -4,6 +4,7 @@ from pathlib import Path
 | 
			
		||||
from shutil import copytree, rmtree, copyfile
 | 
			
		||||
 | 
			
		||||
def load_yaml(path):
 | 
			
		||||
    """Load gallery and site .yaml conf"""
 | 
			
		||||
    if not path.exists():
 | 
			
		||||
        logging.warning(f"[!] YAML file not found: {path}")
 | 
			
		||||
        return {}
 | 
			
		||||
@@ -11,6 +12,7 @@ def load_yaml(path):
 | 
			
		||||
        return yaml.safe_load(f)
 | 
			
		||||
 | 
			
		||||
def load_theme_config(theme_name, themes_dir):
 | 
			
		||||
    """Load theme.yaml"""
 | 
			
		||||
    theme_dir = themes_dir / theme_name
 | 
			
		||||
    theme_config_path = theme_dir / "theme.yaml"
 | 
			
		||||
    if not theme_config_path.exists():
 | 
			
		||||
@@ -20,26 +22,25 @@ def load_theme_config(theme_name, themes_dir):
 | 
			
		||||
    return theme_vars, theme_dir
 | 
			
		||||
 | 
			
		||||
def clear_dir(path: Path):
 | 
			
		||||
    """Clear the output dir"""
 | 
			
		||||
    if not path.exists():
 | 
			
		||||
        path.mkdir(parents=True)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Remove all files and subdirectories inside path, but not path itself
 | 
			
		||||
    for child in path.iterdir():
 | 
			
		||||
        if child.is_file() or child.is_symlink():
 | 
			
		||||
            child.unlink()  # delete file or symlink
 | 
			
		||||
            child.unlink()
 | 
			
		||||
        elif child.is_dir():
 | 
			
		||||
            rmtree(child)  # delete directory and contents
 | 
			
		||||
 | 
			
		||||
# Then replace your ensure_dir with this:
 | 
			
		||||
            rmtree(child)
 | 
			
		||||
 | 
			
		||||
def ensure_dir(path: Path):
 | 
			
		||||
    """Create the output dir if it does not exist"""
 | 
			
		||||
    if not path.exists():
 | 
			
		||||
        path.mkdir(parents=True)
 | 
			
		||||
    else:
 | 
			
		||||
        clear_dir(path)
 | 
			
		||||
 | 
			
		||||
def copy_assets(js_dir, style_dir, build_dir):
 | 
			
		||||
    """Copy public assets to output dir"""
 | 
			
		||||
    for folder in [js_dir, style_dir]:
 | 
			
		||||
        if folder.exists():
 | 
			
		||||
            dest = build_dir / folder.name
 | 
			
		||||
							
								
								
									
										0
									
								
								src/py/webui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/py/webui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										66
									
								
								src/py/webui/upload.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/py/webui/upload.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
import logging
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from flask import Blueprint, request, current_app
 | 
			
		||||
from werkzeug.utils import secure_filename
 | 
			
		||||
from src.py.builder.gallery_builder import update_gallery, update_hero
 | 
			
		||||
 | 
			
		||||
# --- Create Flask blueprint for upload routes ---
 | 
			
		||||
upload_bp = Blueprint("upload", __name__)
 | 
			
		||||
 | 
			
		||||
# --- Allowed file types ---
 | 
			
		||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
 | 
			
		||||
 | 
			
		||||
def allowed_file(filename: str) -> bool:
 | 
			
		||||
    """Check if the uploaded file has an allowed extension."""
 | 
			
		||||
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
 | 
			
		||||
 | 
			
		||||
def save_uploaded_file(file, folder: Path):
 | 
			
		||||
    """Save an uploaded file to the specified folder."""
 | 
			
		||||
    folder.mkdir(parents=True, exist_ok=True)  # Create folder if not exists
 | 
			
		||||
    filename = secure_filename(file.filename)  # Sanitize filename
 | 
			
		||||
    file.save(folder / filename)  # Save to disk
 | 
			
		||||
    logging.info(f"[✓] Uploaded {filename} to {folder}")
 | 
			
		||||
    return filename
 | 
			
		||||
 | 
			
		||||
@upload_bp.route("/api/<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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										363
									
								
								src/py/webui/webui.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								src/py/webui/webui.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,363 @@
 | 
			
		||||
import logging
 | 
			
		||||
import yaml
 | 
			
		||||
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=""
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
 | 
			
		||||
 | 
			
		||||
# --- Photos directory (configurable) ---
 | 
			
		||||
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
 | 
			
		||||
app.config["PHOTOS_DIR"] = PHOTOS_DIR
 | 
			
		||||
 | 
			
		||||
# --- Register upload blueprint ---
 | 
			
		||||
app.register_blueprint(upload_bp)
 | 
			
		||||
 | 
			
		||||
# --- Helper functions for theme editor ---
 | 
			
		||||
def get_theme_name():
 | 
			
		||||
    site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
 | 
			
		||||
    with open(site_yaml_path, "r") as f:
 | 
			
		||||
        site_yaml = yaml.safe_load(f)
 | 
			
		||||
    return site_yaml.get("build", {}).get("theme", "modern")
 | 
			
		||||
 | 
			
		||||
def get_theme_yaml(theme_name):
 | 
			
		||||
    theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
 | 
			
		||||
    with open(theme_yaml_path, "r") as f:
 | 
			
		||||
        return yaml.safe_load(f)
 | 
			
		||||
 | 
			
		||||
def save_theme_yaml(theme_name, theme_yaml):
 | 
			
		||||
    theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
 | 
			
		||||
    with open(theme_yaml_path, "w") as f:
 | 
			
		||||
        yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
 | 
			
		||||
def get_local_fonts(theme_name):
 | 
			
		||||
    fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
 | 
			
		||||
    if not fonts_dir.exists():
 | 
			
		||||
        return []
 | 
			
		||||
    # Return full filenames, not just stem
 | 
			
		||||
    return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
 | 
			
		||||
 | 
			
		||||
# --- Routes ---
 | 
			
		||||
 | 
			
		||||
@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("/api/gallery/delete_all", methods=["POST"])
 | 
			
		||||
def delete_all_gallery_photos():
 | 
			
		||||
    """Delete all gallery photos from disk and YAML."""
 | 
			
		||||
    gallery_dir = PHOTOS_DIR / "gallery"
 | 
			
		||||
    deleted = 0
 | 
			
		||||
    # Remove all files in gallery folder
 | 
			
		||||
    for file in gallery_dir.glob("*"):
 | 
			
		||||
        if file.is_file():
 | 
			
		||||
            file.unlink()
 | 
			
		||||
            deleted += 1
 | 
			
		||||
    # Clear YAML gallery images
 | 
			
		||||
    data = load_yaml(GALLERY_YAML)
 | 
			
		||||
    data["gallery"]["images"] = []
 | 
			
		||||
    save_yaml(data, GALLERY_YAML)
 | 
			
		||||
    return jsonify({"status": "ok", "deleted": deleted})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/hero/delete_all", methods=["POST"])
 | 
			
		||||
def delete_all_hero_photos():
 | 
			
		||||
    """Delete all hero photos from disk and YAML."""
 | 
			
		||||
    hero_dir = PHOTOS_DIR / "hero"
 | 
			
		||||
    deleted = 0
 | 
			
		||||
    # Remove all files in hero folder
 | 
			
		||||
    for file in hero_dir.glob("*"):
 | 
			
		||||
        if file.is_file():
 | 
			
		||||
            file.unlink()
 | 
			
		||||
            deleted += 1
 | 
			
		||||
    # Clear YAML hero images
 | 
			
		||||
    data = load_yaml(GALLERY_YAML)
 | 
			
		||||
    data["hero"]["images"] = []
 | 
			
		||||
    save_yaml(data, GALLERY_YAML)
 | 
			
		||||
    return jsonify({"status": "ok", "deleted": deleted})
 | 
			
		||||
 | 
			
		||||
@app.route("/photos/<section>/<path:filename>")
 | 
			
		||||
def photos(section, filename):
 | 
			
		||||
    """Serve uploaded photos from disk for a specific section."""
 | 
			
		||||
    return send_from_directory(PHOTOS_DIR / section, filename)
 | 
			
		||||
 | 
			
		||||
@app.route("/photos/<path:filename>")
 | 
			
		||||
def serve_photo(filename):
 | 
			
		||||
    """Serve uploaded photos from disk (generic)."""
 | 
			
		||||
    photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
 | 
			
		||||
    return send_from_directory(photos_dir, filename)
 | 
			
		||||
 | 
			
		||||
@app.route("/site-info")
 | 
			
		||||
def site_info():
 | 
			
		||||
    """Serve the site info editor page."""
 | 
			
		||||
    return render_template("site-info/index.html")
 | 
			
		||||
 | 
			
		||||
@app.route("/api/site-info", methods=["GET"])
 | 
			
		||||
def get_site_info():
 | 
			
		||||
    """Return the site info YAML as JSON."""
 | 
			
		||||
    with open(SITE_YAML, "r") as f:
 | 
			
		||||
        data = yaml.safe_load(f)
 | 
			
		||||
    return jsonify(data)
 | 
			
		||||
 | 
			
		||||
@app.route("/api/site-info", methods=["POST"])
 | 
			
		||||
def update_site_info():
 | 
			
		||||
    """Update the site info YAML from frontend JSON."""
 | 
			
		||||
    data = request.json
 | 
			
		||||
    with open(SITE_YAML, "w") as f:
 | 
			
		||||
        yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    return jsonify({"status": "ok"})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/themes")
 | 
			
		||||
def list_themes():
 | 
			
		||||
    """List available themes (folders in config/themes)."""
 | 
			
		||||
    themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
 | 
			
		||||
    themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
 | 
			
		||||
    return jsonify(themes)
 | 
			
		||||
 | 
			
		||||
@app.route("/api/thumbnail/upload", methods=["POST"])
 | 
			
		||||
def upload_thumbnail():
 | 
			
		||||
    """Upload a thumbnail image and update site.yaml."""
 | 
			
		||||
    PHOTOS_DIR = app.config["PHOTOS_DIR"]
 | 
			
		||||
    file = request.files.get("file")
 | 
			
		||||
    if not file:
 | 
			
		||||
        return {"error": "No file provided"}, 400
 | 
			
		||||
    filename = "thumbnail.png"
 | 
			
		||||
    file.save(PHOTOS_DIR / filename)
 | 
			
		||||
    # Update site.yaml
 | 
			
		||||
    with open(SITE_YAML, "r") as f:
 | 
			
		||||
        data = yaml.safe_load(f)
 | 
			
		||||
    data.setdefault("social", {})["thumbnail"] = filename
 | 
			
		||||
    with open(SITE_YAML, "w") as f:
 | 
			
		||||
        yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    return jsonify({"status": "ok", "filename": filename})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/thumbnail/remove", methods=["POST"])
 | 
			
		||||
def remove_thumbnail():
 | 
			
		||||
    """Remove the thumbnail image and update site.yaml."""
 | 
			
		||||
    PHOTOS_DIR = app.config["PHOTOS_DIR"]
 | 
			
		||||
    thumbnail_path = PHOTOS_DIR / "thumbnail.png"
 | 
			
		||||
    # Remove thumbnail file if exists
 | 
			
		||||
    if thumbnail_path.exists():
 | 
			
		||||
        thumbnail_path.unlink()
 | 
			
		||||
    # Update site.yaml to remove thumbnail key
 | 
			
		||||
    with open(SITE_YAML, "r") as f:
 | 
			
		||||
        data = yaml.safe_load(f)
 | 
			
		||||
    if "social" in data and "thumbnail" in data["social"]:
 | 
			
		||||
        data["social"]["thumbnail"] = ""
 | 
			
		||||
    with open(SITE_YAML, "w") as f:
 | 
			
		||||
        yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    return jsonify({"status": "ok"})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/theme/upload", methods=["POST"])
 | 
			
		||||
def upload_theme():
 | 
			
		||||
    """Upload a custom theme folder and save it in config/themes."""
 | 
			
		||||
    themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
 | 
			
		||||
    files = request.files.getlist("files")
 | 
			
		||||
    if not files:
 | 
			
		||||
        return jsonify({"error": "No files provided"}), 400
 | 
			
		||||
    # Get folder name from first file's webkitRelativePath
 | 
			
		||||
    first_path = files[0].filename
 | 
			
		||||
    folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
 | 
			
		||||
    theme_folder = themes_dir / folder_name
 | 
			
		||||
    theme_folder.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
    for file in files:
 | 
			
		||||
        rel_path = Path(file.filename)
 | 
			
		||||
        dest_path = theme_folder / rel_path.relative_to(folder_name)
 | 
			
		||||
        dest_path.parent.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
        file.save(dest_path)
 | 
			
		||||
    return jsonify({"status": "ok", "theme": folder_name})
 | 
			
		||||
 | 
			
		||||
# --- Theme Editor API ---
 | 
			
		||||
@app.route("/theme-editor")
 | 
			
		||||
def theme_editor():
 | 
			
		||||
    """Serve the theme editor page."""
 | 
			
		||||
    return render_template("theme-editor/index.html")
 | 
			
		||||
 | 
			
		||||
@app.route("/api/theme-info", methods=["GET", "POST"])
 | 
			
		||||
def api_theme_info():
 | 
			
		||||
    theme_name = get_theme_name()
 | 
			
		||||
    if request.method == "GET":
 | 
			
		||||
        theme_yaml = get_theme_yaml(theme_name)
 | 
			
		||||
        google_fonts = theme_yaml.get("google_fonts", [])
 | 
			
		||||
        return jsonify({
 | 
			
		||||
            "theme_name": theme_name,
 | 
			
		||||
            "theme_yaml": theme_yaml,
 | 
			
		||||
            "google_fonts": google_fonts
 | 
			
		||||
        })
 | 
			
		||||
    else:
 | 
			
		||||
        data = request.get_json()
 | 
			
		||||
        theme_yaml = data.get("theme_yaml")
 | 
			
		||||
        theme_name = data.get("theme_name", theme_name)
 | 
			
		||||
        save_theme_yaml(theme_name, theme_yaml)
 | 
			
		||||
        return jsonify({"status": "ok"})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/local-fonts")
 | 
			
		||||
def api_local_fonts():
 | 
			
		||||
    theme_name = request.args.get("theme")
 | 
			
		||||
    fonts = get_local_fonts(theme_name)
 | 
			
		||||
    return jsonify(fonts)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.route("/api/favicon/upload", methods=["POST"])
 | 
			
		||||
def upload_favicon():
 | 
			
		||||
    """Upload favicon to theme folder and update theme.yaml."""
 | 
			
		||||
    theme_name = request.form.get("theme")
 | 
			
		||||
    file = request.files.get("file")
 | 
			
		||||
    if not file or not theme_name:
 | 
			
		||||
        return jsonify({"error": "Missing file or theme"}), 400
 | 
			
		||||
    ext = Path(file.filename).suffix.lower()
 | 
			
		||||
    if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
 | 
			
		||||
        return jsonify({"error": "Invalid file type"}), 400
 | 
			
		||||
    filename = "favicon" + ext
 | 
			
		||||
    theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
 | 
			
		||||
    file.save(theme_dir / filename)
 | 
			
		||||
    # Update theme.yaml
 | 
			
		||||
    theme_yaml_path = theme_dir / "theme.yaml"
 | 
			
		||||
    with open(theme_yaml_path, "r") as f:
 | 
			
		||||
        theme_yaml = yaml.safe_load(f)
 | 
			
		||||
    theme_yaml.setdefault("favicon", {})["path"] = filename
 | 
			
		||||
    with open(theme_yaml_path, "w") as f:
 | 
			
		||||
        yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    return jsonify({"status": "ok", "filename": filename})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/favicon/remove", methods=["POST"])
 | 
			
		||||
def remove_favicon():
 | 
			
		||||
    """Remove favicon from theme folder and update theme.yaml."""
 | 
			
		||||
    data = request.get_json()
 | 
			
		||||
    theme_name = data.get("theme")
 | 
			
		||||
    if not theme_name:
 | 
			
		||||
        return jsonify({"error": "Missing theme"}), 400
 | 
			
		||||
    theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
 | 
			
		||||
    # Remove favicon file
 | 
			
		||||
    for ext in [".png", ".jpg", ".jpeg", ".ico"]:
 | 
			
		||||
        favicon_path = theme_dir / f"favicon{ext}"
 | 
			
		||||
        if favicon_path.exists():
 | 
			
		||||
            favicon_path.unlink()
 | 
			
		||||
    # Update theme.yaml
 | 
			
		||||
    theme_yaml_path = theme_dir / "theme.yaml"
 | 
			
		||||
    with open(theme_yaml_path, "r") as f:
 | 
			
		||||
        theme_yaml = yaml.safe_load(f)
 | 
			
		||||
    if "favicon" in theme_yaml:
 | 
			
		||||
        theme_yaml["favicon"]["path"] = ""
 | 
			
		||||
    with open(theme_yaml_path, "w") as f:
 | 
			
		||||
        yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
 | 
			
		||||
    return jsonify({"status": "ok"})
 | 
			
		||||
 | 
			
		||||
@app.route("/themes/<theme>/<filename>")
 | 
			
		||||
def serve_theme_asset(theme, filename):
 | 
			
		||||
    theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
 | 
			
		||||
    return send_from_directory(theme_dir, filename)
 | 
			
		||||
 | 
			
		||||
@app.route("/api/font/upload", methods=["POST"])
 | 
			
		||||
def upload_font():
 | 
			
		||||
    """Upload a font file to the theme's fonts folder (only .woff/.woff2 allowed)."""
 | 
			
		||||
    theme_name = request.form.get("theme")
 | 
			
		||||
    file = request.files.get("file")
 | 
			
		||||
    if not file or not theme_name:
 | 
			
		||||
        return jsonify({"error": "Missing file or theme"}), 400
 | 
			
		||||
    ext = Path(file.filename).suffix.lower()
 | 
			
		||||
    if ext not in [".woff", ".woff2"]:
 | 
			
		||||
        return jsonify({"error": "Invalid font file type"}), 400
 | 
			
		||||
    fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
 | 
			
		||||
    fonts_dir.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
    file.save(fonts_dir / file.filename)
 | 
			
		||||
    return jsonify({"status": "ok", "filename": file.filename})
 | 
			
		||||
 | 
			
		||||
@app.route("/api/font/remove", methods=["POST"])
 | 
			
		||||
def remove_font():
 | 
			
		||||
    """Remove a font file from the theme's fonts folder."""
 | 
			
		||||
    data = request.get_json()
 | 
			
		||||
    theme_name = data.get("theme")
 | 
			
		||||
    font = data.get("font")
 | 
			
		||||
    if not theme_name or not font:
 | 
			
		||||
        return jsonify({"error": "Missing theme or font"}), 400
 | 
			
		||||
    fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
 | 
			
		||||
    font_path = fonts_dir / font
 | 
			
		||||
    if font_path.exists():
 | 
			
		||||
        font_path.unlink()
 | 
			
		||||
        return jsonify({"status": "ok"})
 | 
			
		||||
    return jsonify({"error": "Font not found"}), 404
 | 
			
		||||
 | 
			
		||||
# --- 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  | 
							
								
								
									
										90
									
								
								src/webui/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/webui/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <title>Lumeex</title>
 | 
			
		||||
  <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">Site info</a>
 | 
			
		||||
          <li class="nav-item appear2"><a href="#">Theme info</a>
 | 
			
		||||
          <li class="nav-item appear2"><a href="#">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>
 | 
			
		||||
      <div class="upload-actions-row">
 | 
			
		||||
        <label for="upload-hero" class="up-btn">
 | 
			
		||||
          📸 Upload photos
 | 
			
		||||
        </label>
 | 
			
		||||
        <button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <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>
 | 
			
		||||
      <div class="upload-actions-row">
 | 
			
		||||
        <label for="upload-gallery" class="up-btn">
 | 
			
		||||
          📸 Upload photos
 | 
			
		||||
        </label>
 | 
			
		||||
        <button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
 | 
			
		||||
      <div id="gallery"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
 | 
			
		||||
    <script src="{{ url_for('static', filename='js/upload.js') }}" defer></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>
 | 
			
		||||
							
								
								
									
										411
									
								
								src/webui/js/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										411
									
								
								src/webui/js/main.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,411 @@
 | 
			
		||||
// --- 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 || []);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Show/hide Remove All button
 | 
			
		||||
  const removeAllBtn = document.getElementById('remove-all-gallery');
 | 
			
		||||
  if (removeAllBtn) {
 | 
			
		||||
    removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Render tags for a single image ---
 | 
			
		||||
function renderTags(imgIndex, tags) {
 | 
			
		||||
  const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
 | 
			
		||||
  const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
 | 
			
		||||
 | 
			
		||||
  tagsDisplay.innerHTML = '';
 | 
			
		||||
  inputContainer.innerHTML = '';
 | 
			
		||||
 | 
			
		||||
  tags.forEach(tag => {
 | 
			
		||||
    const span = document.createElement('span');
 | 
			
		||||
    span.className = 'tag';
 | 
			
		||||
    span.textContent = tag;
 | 
			
		||||
 | 
			
		||||
    const remove = document.createElement('span');
 | 
			
		||||
    remove.className = 'remove-tag';
 | 
			
		||||
    remove.textContent = '×';
 | 
			
		||||
    remove.onclick = () => {
 | 
			
		||||
      tags.splice(tags.indexOf(tag), 1);
 | 
			
		||||
      updateTags(imgIndex, tags);
 | 
			
		||||
      renderTags(imgIndex, tags);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    span.appendChild(remove);
 | 
			
		||||
    tagsDisplay.appendChild(span);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const input = document.createElement('input');
 | 
			
		||||
  input.type = 'text';
 | 
			
		||||
  input.placeholder = 'Add tag...';
 | 
			
		||||
  inputContainer.appendChild(input);
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
    container.appendChild(div);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Show/hide Remove All button
 | 
			
		||||
  const removeAllBtn = document.getElementById('remove-all-hero');
 | 
			
		||||
  if (removeAllBtn) {
 | 
			
		||||
    removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Save gallery to server ---
 | 
			
		||||
async function saveGallery() {
 | 
			
		||||
  await fetch('/api/gallery/update', {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    headers: {'Content-Type': 'application/json'},
 | 
			
		||||
    body: JSON.stringify(galleryImages)
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Save hero to server ---
 | 
			
		||||
async function saveHero() {
 | 
			
		||||
  await fetch('/api/hero/update', {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    headers: {'Content-Type': 'application/json'},
 | 
			
		||||
    body: JSON.stringify(heroImages)
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Save all changes ---
 | 
			
		||||
async function saveChanges() {
 | 
			
		||||
  await saveGallery();
 | 
			
		||||
  await saveHero();
 | 
			
		||||
  showToast('✅ Changes saved!', "success");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Refresh gallery from folder ---
 | 
			
		||||
async function refreshGallery() {
 | 
			
		||||
  await fetch('/api/gallery/refresh', { method: 'POST' });
 | 
			
		||||
  await loadData();
 | 
			
		||||
  showToast('🔄 Gallery updated from photos/gallery folder', "success");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Refresh hero from folder ---
 | 
			
		||||
async function refreshHero() {
 | 
			
		||||
  await fetch('/api/hero/refresh', { method: 'POST' });
 | 
			
		||||
  await loadData();
 | 
			
		||||
  showToast('🔄 Hero updated from photos/hero folder', "success");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Show toast notification ---
 | 
			
		||||
function showToast(message, type = "success", duration = 3000) {
 | 
			
		||||
  const container = document.getElementById("toast-container");
 | 
			
		||||
  if (!container) return;
 | 
			
		||||
 | 
			
		||||
  const toast = document.createElement("div");
 | 
			
		||||
  toast.className = `toast ${type}`;
 | 
			
		||||
  toast.textContent = message;
 | 
			
		||||
  container.appendChild(toast);
 | 
			
		||||
 | 
			
		||||
  requestAnimationFrame(() => toast.classList.add("show"));
 | 
			
		||||
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    toast.classList.remove("show");
 | 
			
		||||
    toast.addEventListener("transitionend", () => toast.remove());
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null }
 | 
			
		||||
 | 
			
		||||
// --- Show delete confirmation modal ---
 | 
			
		||||
function showDeleteModal(type, index = null) {
 | 
			
		||||
  pendingDelete = { type, index };
 | 
			
		||||
  const modalText = document.getElementById('delete-modal-text');
 | 
			
		||||
  if (type === 'gallery-all') {
 | 
			
		||||
    modalText.textContent = "Are you sure you want to delete ALL gallery images?";
 | 
			
		||||
  } else if (type === 'hero-all') {
 | 
			
		||||
    modalText.textContent = "Are you sure you want to delete ALL hero images?";
 | 
			
		||||
  } else {
 | 
			
		||||
    modalText.textContent = "Are you sure you want to delete this image?";
 | 
			
		||||
  }
 | 
			
		||||
  document.getElementById('delete-modal').style.display = 'flex';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Hide modal ---
 | 
			
		||||
function hideDeleteModal() {
 | 
			
		||||
  document.getElementById('delete-modal').style.display = 'none';
 | 
			
		||||
  pendingDelete = null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Confirm deletion ---
 | 
			
		||||
async function confirmDelete() {
 | 
			
		||||
  if (!pendingDelete) return;
 | 
			
		||||
  if (pendingDelete.type === 'gallery') {
 | 
			
		||||
    await actuallyDeleteGalleryImage(pendingDelete.index);
 | 
			
		||||
  } else if (pendingDelete.type === 'hero') {
 | 
			
		||||
    await actuallyDeleteHeroImage(pendingDelete.index);
 | 
			
		||||
  } else if (pendingDelete.type === 'gallery-all') {
 | 
			
		||||
    await actuallyDeleteAllGalleryImages();
 | 
			
		||||
  } else if (pendingDelete.type === 'hero-all') {
 | 
			
		||||
    await actuallyDeleteAllHeroImages();
 | 
			
		||||
  }
 | 
			
		||||
  hideDeleteModal();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Actual delete functions ---
 | 
			
		||||
async function actuallyDeleteGalleryImage(index) {
 | 
			
		||||
  const img = galleryImages[index];
 | 
			
		||||
  try {
 | 
			
		||||
    const res = await fetch('/api/gallery/delete', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {'Content-Type': 'application/json'},
 | 
			
		||||
      body: JSON.stringify({ src: img.src.split('/').pop() })
 | 
			
		||||
    });
 | 
			
		||||
    const data = await res.json();
 | 
			
		||||
    if (res.ok) {
 | 
			
		||||
      galleryImages.splice(index, 1);
 | 
			
		||||
      renderGallery();
 | 
			
		||||
      await saveGallery();
 | 
			
		||||
      showToast("✅ Gallery image deleted!", "success");
 | 
			
		||||
    } else showToast("Error: " + data.error, "error");
 | 
			
		||||
  } catch(err) {
 | 
			
		||||
    console.error(err);
 | 
			
		||||
    showToast("Server error!", "error");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function actuallyDeleteHeroImage(index) {
 | 
			
		||||
  const img = heroImages[index];
 | 
			
		||||
  try {
 | 
			
		||||
    const res = await fetch('/api/hero/delete', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {'Content-Type': 'application/json'},
 | 
			
		||||
      body: JSON.stringify({ src: img.src.split('/').pop() })
 | 
			
		||||
    });
 | 
			
		||||
    const data = await res.json();
 | 
			
		||||
    if (res.ok) {
 | 
			
		||||
      heroImages.splice(index, 1);
 | 
			
		||||
      renderHero();
 | 
			
		||||
      await saveHero();
 | 
			
		||||
      showToast("✅ Hero image deleted!", "success");
 | 
			
		||||
    } else showToast("Error: " + data.error, "error");
 | 
			
		||||
  } catch(err) {
 | 
			
		||||
    console.error(err);
 | 
			
		||||
    showToast("Server error!", "error");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Bulk delete functions ---
 | 
			
		||||
async function actuallyDeleteAllGalleryImages() {
 | 
			
		||||
  try {
 | 
			
		||||
    const res = await fetch('/api/gallery/delete_all', { method: 'POST' });
 | 
			
		||||
    const data = await res.json();
 | 
			
		||||
    if (res.ok) {
 | 
			
		||||
      galleryImages = [];
 | 
			
		||||
      renderGallery();
 | 
			
		||||
      await saveGallery();
 | 
			
		||||
      showToast("✅ All gallery images removed!", "success");
 | 
			
		||||
    } else showToast("Error: " + data.error, "error");
 | 
			
		||||
  } catch(err) {
 | 
			
		||||
    console.error(err);
 | 
			
		||||
    showToast("Server error!", "error");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function actuallyDeleteAllHeroImages() {
 | 
			
		||||
  try {
 | 
			
		||||
    const res = await fetch('/api/hero/delete_all', { method: 'POST' });
 | 
			
		||||
    const data = await res.json();
 | 
			
		||||
    if (res.ok) {
 | 
			
		||||
      heroImages = [];
 | 
			
		||||
      renderHero();
 | 
			
		||||
      await saveHero();
 | 
			
		||||
      showToast("✅ All hero images removed!", "success");
 | 
			
		||||
    } else showToast("Error: " + data.error, "error");
 | 
			
		||||
  } catch(err) {
 | 
			
		||||
    console.error(err);
 | 
			
		||||
    showToast("Server error!", "error");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --- Modal event listeners and bulk delete buttons ---
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  document.getElementById('delete-modal-close').onclick = hideDeleteModal;
 | 
			
		||||
  document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
 | 
			
		||||
  document.getElementById('delete-modal-confirm').onclick = confirmDelete;
 | 
			
		||||
 | 
			
		||||
  // Bulk delete buttons
 | 
			
		||||
  const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
 | 
			
		||||
  const removeAllHeroBtn = document.getElementById('remove-all-hero');
 | 
			
		||||
  if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
 | 
			
		||||
  if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// --- Initialize ---
 | 
			
		||||
loadData();
 | 
			
		||||
							
								
								
									
										361
									
								
								src/webui/js/site-info.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										361
									
								
								src/webui/js/site-info.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,361 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
  // Form and menu logic
 | 
			
		||||
  const form = document.getElementById("site-info-form");
 | 
			
		||||
  const menuList = document.getElementById("menu-items-list");
 | 
			
		||||
  const addMenuBtn = document.getElementById("add-menu-item");
 | 
			
		||||
 | 
			
		||||
  let menuItems = [];
 | 
			
		||||
 | 
			
		||||
  // Render menu items
 | 
			
		||||
  function renderMenuItems() {
 | 
			
		||||
    menuList.innerHTML = "";
 | 
			
		||||
    menuItems.forEach((item, idx) => {
 | 
			
		||||
      const div = document.createElement("div");
 | 
			
		||||
      div.style.display = "flex";
 | 
			
		||||
      div.style.gap = "8px";
 | 
			
		||||
      div.style.marginBottom = "6px";
 | 
			
		||||
      div.innerHTML = `
 | 
			
		||||
        <input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
 | 
			
		||||
        <input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
 | 
			
		||||
        <button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
 | 
			
		||||
      `;
 | 
			
		||||
      menuList.appendChild(div);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Update menu items from inputs
 | 
			
		||||
  function updateMenuItemsFromInputs() {
 | 
			
		||||
    const inputs = menuList.querySelectorAll("input");
 | 
			
		||||
    const items = [];
 | 
			
		||||
    for (let i = 0; i < inputs.length; i += 2) {
 | 
			
		||||
      const label = inputs[i].value.trim();
 | 
			
		||||
      const href = inputs[i + 1].value.trim();
 | 
			
		||||
      if (label || href) items.push({ label, href });
 | 
			
		||||
    }
 | 
			
		||||
    menuItems = items;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Intellectual property paragraphs logic
 | 
			
		||||
  const ipList = document.getElementById("ip-list");
 | 
			
		||||
  const addIpBtn = document.getElementById("add-ip-paragraph");
 | 
			
		||||
  let ipParagraphs = [];
 | 
			
		||||
 | 
			
		||||
  // Render IP paragraphs
 | 
			
		||||
  function renderIpParagraphs() {
 | 
			
		||||
    ipList.innerHTML = "";
 | 
			
		||||
    ipParagraphs.forEach((item, idx) => {
 | 
			
		||||
      const div = document.createElement("div");
 | 
			
		||||
      div.style.display = "flex";
 | 
			
		||||
      div.style.gap = "8px";
 | 
			
		||||
      div.style.marginBottom = "6px";
 | 
			
		||||
      div.innerHTML = `
 | 
			
		||||
        <textarea placeholder="Paragraph" style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
 | 
			
		||||
        <button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
 | 
			
		||||
      `;
 | 
			
		||||
      ipList.appendChild(div);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Update IP paragraphs from textareas
 | 
			
		||||
  function updateIpParagraphsFromInputs() {
 | 
			
		||||
    const textareas = ipList.querySelectorAll("textarea");
 | 
			
		||||
    ipParagraphs = Array.from(textareas).map(textarea => ({
 | 
			
		||||
      paragraph: textarea.value.trim()
 | 
			
		||||
    })).filter(item => item.paragraph !== "");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Build options
 | 
			
		||||
  const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
 | 
			
		||||
  const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
 | 
			
		||||
 | 
			
		||||
  // Theme select
 | 
			
		||||
  const themeSelect = document.getElementById("theme-select");
 | 
			
		||||
 | 
			
		||||
  // Thumbnail upload and modal logic
 | 
			
		||||
  const thumbnailInput = form?.elements["social.thumbnail"];
 | 
			
		||||
  const thumbnailUpload = document.getElementById("thumbnail-upload");
 | 
			
		||||
  const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
 | 
			
		||||
  const thumbnailPreview = document.getElementById("thumbnail-preview");
 | 
			
		||||
  const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
 | 
			
		||||
 | 
			
		||||
  // Modal elements for delete confirmation
 | 
			
		||||
  const deleteModal = document.getElementById("delete-modal");
 | 
			
		||||
  const deleteModalClose = document.getElementById("delete-modal-close");
 | 
			
		||||
  const deleteModalConfirm = document.getElementById("delete-modal-confirm");
 | 
			
		||||
  const deleteModalCancel = document.getElementById("delete-modal-cancel");
 | 
			
		||||
 | 
			
		||||
  // Show/hide thumbnail preview, remove button, and choose button
 | 
			
		||||
  function updateThumbnailPreview(src) {
 | 
			
		||||
    if (thumbnailPreview) {
 | 
			
		||||
      thumbnailPreview.src = src || "";
 | 
			
		||||
      thumbnailPreview.style.display = src ? "block" : "none";
 | 
			
		||||
    }
 | 
			
		||||
    if (removeThumbnailBtn) {
 | 
			
		||||
      removeThumbnailBtn.style.display = src ? "inline-block" : "none";
 | 
			
		||||
    }
 | 
			
		||||
    if (chooseThumbnailBtn) {
 | 
			
		||||
      chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Choose thumbnail button triggers file input
 | 
			
		||||
  if (chooseThumbnailBtn && thumbnailUpload) {
 | 
			
		||||
    chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Handle thumbnail upload and refresh preview (with cache busting)
 | 
			
		||||
  if (thumbnailUpload) {
 | 
			
		||||
    thumbnailUpload.addEventListener("change", async (e) => {
 | 
			
		||||
      const file = e.target.files[0];
 | 
			
		||||
      if (!file) return;
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      formData.append("file", file);
 | 
			
		||||
      const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        if (thumbnailInput) thumbnailInput.value = result.filename;
 | 
			
		||||
        updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`);
 | 
			
		||||
        showToast("Thumbnail uploaded!", "success");
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error uploading thumbnail", "error");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Remove thumbnail button triggers modal
 | 
			
		||||
  if (removeThumbnailBtn) {
 | 
			
		||||
    removeThumbnailBtn.addEventListener("click", () => {
 | 
			
		||||
      deleteModal.style.display = "flex";
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Modal logic for thumbnail deletion
 | 
			
		||||
  if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) {
 | 
			
		||||
    deleteModalClose.onclick = deleteModalCancel.onclick = () => {
 | 
			
		||||
      deleteModal.style.display = "none";
 | 
			
		||||
    };
 | 
			
		||||
    window.onclick = function(event) {
 | 
			
		||||
      if (event.target === deleteModal) {
 | 
			
		||||
        deleteModal.style.display = "none";
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    deleteModalConfirm.onclick = async () => {
 | 
			
		||||
      const res = await fetch("/api/thumbnail/remove", { method: "POST" });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        if (thumbnailInput) thumbnailInput.value = "";
 | 
			
		||||
        updateThumbnailPreview("");
 | 
			
		||||
        showToast("Thumbnail removed!", "success");
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error removing thumbnail", "error");
 | 
			
		||||
      }
 | 
			
		||||
      deleteModal.style.display = "none";
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Theme upload logic (custom theme folder)
 | 
			
		||||
  const themeUpload = document.getElementById("theme-upload");
 | 
			
		||||
  const chooseThemeBtn = document.getElementById("choose-theme-btn");
 | 
			
		||||
  if (chooseThemeBtn && themeUpload) {
 | 
			
		||||
    chooseThemeBtn.addEventListener("click", () => themeUpload.click());
 | 
			
		||||
    themeUpload.addEventListener("change", async (e) => {
 | 
			
		||||
      const files = Array.from(e.target.files);
 | 
			
		||||
      if (files.length === 0) return;
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      files.forEach(file => {
 | 
			
		||||
        formData.append("files", file, file.webkitRelativePath || file.name);
 | 
			
		||||
      });
 | 
			
		||||
      const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        showToast("Theme uploaded!", "success");
 | 
			
		||||
        // Refresh theme select after upload
 | 
			
		||||
        fetch("/api/themes")
 | 
			
		||||
          .then(res => res.json())
 | 
			
		||||
          .then(themes => {
 | 
			
		||||
            themeSelect.innerHTML = "";
 | 
			
		||||
            themes.forEach(theme => {
 | 
			
		||||
              const option = document.createElement("option");
 | 
			
		||||
              option.value = theme;
 | 
			
		||||
              option.textContent = theme;
 | 
			
		||||
              themeSelect.appendChild(option);
 | 
			
		||||
            });
 | 
			
		||||
          });
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error uploading theme", "error");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Fetch theme list and populate select
 | 
			
		||||
  if (themeSelect) {
 | 
			
		||||
    fetch("/api/themes")
 | 
			
		||||
      .then(res => res.json())
 | 
			
		||||
      .then(themes => {
 | 
			
		||||
        themeSelect.innerHTML = "";
 | 
			
		||||
        themes.forEach(theme => {
 | 
			
		||||
          const option = document.createElement("option");
 | 
			
		||||
          option.value = theme;
 | 
			
		||||
          option.textContent = theme;
 | 
			
		||||
          themeSelect.appendChild(option);
 | 
			
		||||
        });
 | 
			
		||||
        // Set selected value after loading config
 | 
			
		||||
        fetch("/api/site-info")
 | 
			
		||||
          .then(res => res.json())
 | 
			
		||||
          .then(data => {
 | 
			
		||||
            themeSelect.value = data.build?.theme || "";
 | 
			
		||||
          });
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Load config from server and populate form
 | 
			
		||||
  if (form) {
 | 
			
		||||
    fetch("/api/site-info")
 | 
			
		||||
      .then(res => res.json())
 | 
			
		||||
      .then(data => {
 | 
			
		||||
        ipParagraphs = Array.isArray(data.legals?.intellectual_property)
 | 
			
		||||
          ? data.legals.intellectual_property
 | 
			
		||||
          : [];
 | 
			
		||||
        renderIpParagraphs();
 | 
			
		||||
        menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
 | 
			
		||||
        renderMenuItems();
 | 
			
		||||
        form.elements["info.title"].value = data.info?.title || "";
 | 
			
		||||
        form.elements["info.subtitle"].value = data.info?.subtitle || "";
 | 
			
		||||
        form.elements["info.description"].value = data.info?.description || "";
 | 
			
		||||
        form.elements["info.canonical"].value = data.info?.canonical || "";
 | 
			
		||||
        form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
 | 
			
		||||
        form.elements["info.author"].value = data.info?.author || "";
 | 
			
		||||
        form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
 | 
			
		||||
        if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
 | 
			
		||||
        updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
 | 
			
		||||
        form.elements["footer.copyright"].value = data.footer?.copyright || "";
 | 
			
		||||
        form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
 | 
			
		||||
        if (themeSelect) {
 | 
			
		||||
          themeSelect.value = data.build?.theme || "";
 | 
			
		||||
        }
 | 
			
		||||
        form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
 | 
			
		||||
        form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
 | 
			
		||||
        form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
 | 
			
		||||
        // Build checkboxes
 | 
			
		||||
        if (convertImagesCheckbox) {
 | 
			
		||||
          convertImagesCheckbox.checked = !!data.build?.convert_images;
 | 
			
		||||
        }
 | 
			
		||||
        if (resizeImagesCheckbox) {
 | 
			
		||||
          resizeImagesCheckbox.checked = !!data.build?.resize_images;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Add menu item
 | 
			
		||||
  if (addMenuBtn) {
 | 
			
		||||
    addMenuBtn.addEventListener("click", () => {
 | 
			
		||||
      menuItems.push({ label: "", href: "" });
 | 
			
		||||
      renderMenuItems();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Remove menu item
 | 
			
		||||
  menuList.addEventListener("click", (e) => {
 | 
			
		||||
    if (e.target.classList.contains("remove-menu-item")) {
 | 
			
		||||
      const idx = parseInt(e.target.getAttribute("data-idx"));
 | 
			
		||||
      menuItems.splice(idx, 1);
 | 
			
		||||
      renderMenuItems();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Update menuItems on input change
 | 
			
		||||
  menuList.addEventListener("input", () => {
 | 
			
		||||
    updateMenuItemsFromInputs();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Add paragraph
 | 
			
		||||
  if (addIpBtn) {
 | 
			
		||||
    addIpBtn.addEventListener("click", () => {
 | 
			
		||||
      ipParagraphs.push({ paragraph: "" });
 | 
			
		||||
      renderIpParagraphs();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Remove paragraph
 | 
			
		||||
  ipList.addEventListener("click", (e) => {
 | 
			
		||||
    if (e.target.classList.contains("remove-ip-paragraph")) {
 | 
			
		||||
      const idx = parseInt(e.target.getAttribute("data-idx"));
 | 
			
		||||
      ipParagraphs.splice(idx, 1);
 | 
			
		||||
      renderIpParagraphs();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Update ipParagraphs on input change
 | 
			
		||||
  ipList.addEventListener("input", () => {
 | 
			
		||||
    updateIpParagraphsFromInputs();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Save config to server
 | 
			
		||||
  if (form) {
 | 
			
		||||
    form.addEventListener("submit", async (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      updateMenuItemsFromInputs();
 | 
			
		||||
      updateIpParagraphsFromInputs();
 | 
			
		||||
 | 
			
		||||
      const build = {
 | 
			
		||||
        theme: themeSelect ? themeSelect.value : "",
 | 
			
		||||
        convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
 | 
			
		||||
        resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      const payload = {
 | 
			
		||||
        info: {
 | 
			
		||||
          title: form.elements["info.title"].value,
 | 
			
		||||
          subtitle: form.elements["info.subtitle"].value,
 | 
			
		||||
          description: form.elements["info.description"].value,
 | 
			
		||||
          canonical: form.elements["info.canonical"].value,
 | 
			
		||||
          keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
 | 
			
		||||
          author: form.elements["info.author"].value
 | 
			
		||||
        },
 | 
			
		||||
        social: {
 | 
			
		||||
          instagram_url: form.elements["social.instagram_url"].value,
 | 
			
		||||
          thumbnail: thumbnailInput ? thumbnailInput.value : ""
 | 
			
		||||
        },
 | 
			
		||||
        menu: {
 | 
			
		||||
          items: menuItems
 | 
			
		||||
        },
 | 
			
		||||
        footer: {
 | 
			
		||||
          copyright: form.elements["footer.copyright"].value,
 | 
			
		||||
          legal_label: form.elements["footer.legal_label"].value
 | 
			
		||||
        },
 | 
			
		||||
        build,
 | 
			
		||||
        legals: {
 | 
			
		||||
          hoster_name: form.elements["legals.hoster_name"].value,
 | 
			
		||||
          hoster_address: form.elements["legals.hoster_address"].value,
 | 
			
		||||
          hoster_contact: form.elements["legals.hoster_contact"].value,
 | 
			
		||||
          intellectual_property: ipParagraphs
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      const res = await fetch("/api/site-info", {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        headers: { "Content-Type": "application/json" },
 | 
			
		||||
        body: JSON.stringify(payload)
 | 
			
		||||
      });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        showToast("✅ Site info saved!", "success");
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("❌ Error saving site info", "error");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										393
									
								
								src/webui/js/theme-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										393
									
								
								src/webui/js/theme-editor.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,393 @@
 | 
			
		||||
async function fetchThemeInfo() {
 | 
			
		||||
  const res = await fetch("/api/theme-info");
 | 
			
		||||
  return await res.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function fetchLocalFonts(theme) {
 | 
			
		||||
  const res = await fetch(`/api/local-fonts?theme=${encodeURIComponent(theme)}`);
 | 
			
		||||
  return await res.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function removeFont(theme, font) {
 | 
			
		||||
  const res = await fetch("/api/font/remove", {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: { "Content-Type": "application/json" },
 | 
			
		||||
    body: JSON.stringify({ theme, font })
 | 
			
		||||
  });
 | 
			
		||||
  return await res.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function showToast(message, type = "success", duration = 3000) {
 | 
			
		||||
  const container = document.getElementById("toast-container");
 | 
			
		||||
  if (!container) return;
 | 
			
		||||
  const toast = document.createElement("div");
 | 
			
		||||
  toast.className = `toast ${type}`;
 | 
			
		||||
  toast.textContent = message;
 | 
			
		||||
  container.appendChild(toast);
 | 
			
		||||
  requestAnimationFrame(() => toast.classList.add("show"));
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    toast.classList.remove("show");
 | 
			
		||||
    setTimeout(() => container.removeChild(toast), 300);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setColorInput(colorId, textId, value) {
 | 
			
		||||
  const colorInput = document.getElementById(colorId);
 | 
			
		||||
  const textInput = document.getElementById(textId);
 | 
			
		||||
  if (colorInput) colorInput.value = value;
 | 
			
		||||
  if (textInput) textInput.value = value;
 | 
			
		||||
  if (colorInput && textInput) {
 | 
			
		||||
    colorInput.addEventListener("input", () => {
 | 
			
		||||
      textInput.value = colorInput.value;
 | 
			
		||||
    });
 | 
			
		||||
    textInput.addEventListener("input", () => {
 | 
			
		||||
      colorInput.value = textInput.value;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setFontDropdown(selectId, value, options) {
 | 
			
		||||
  const select = document.getElementById(selectId);
 | 
			
		||||
  if (!select) return;
 | 
			
		||||
  select.innerHTML = options.map(opt =>
 | 
			
		||||
    `<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
 | 
			
		||||
  ).join("");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setFallbackDropdown(selectId, value) {
 | 
			
		||||
  const select = document.getElementById(selectId);
 | 
			
		||||
  if (!select) return;
 | 
			
		||||
  select.value = (value === "serif" || value === "sans-serif") ? value : "sans-serif";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setTextInput(inputId, value) {
 | 
			
		||||
  const input = document.getElementById(inputId);
 | 
			
		||||
  if (input) input.value = value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renderGoogleFonts(googleFonts) {
 | 
			
		||||
  const container = document.getElementById("google-fonts-fields");
 | 
			
		||||
  container.innerHTML = "";
 | 
			
		||||
  googleFonts.forEach((font, idx) => {
 | 
			
		||||
    container.innerHTML += `
 | 
			
		||||
      <div class="input-field" data-idx="${idx}">
 | 
			
		||||
        <label>Family</label>
 | 
			
		||||
        <input type="text" name="google_fonts[${idx}][family]" value="${font.family || ""}">
 | 
			
		||||
        <label>Weights (comma separated)</label>
 | 
			
		||||
        <input type="text" name="google_fonts[${idx}][weights]" value="${(font.weights || []).join(',')}">
 | 
			
		||||
        <button type="button" class="remove-google-font" data-idx="${idx}">Remove</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renderLocalFonts(fonts) {
 | 
			
		||||
  const listDiv = document.getElementById("local-fonts-list");
 | 
			
		||||
  if (!listDiv) return;
 | 
			
		||||
  listDiv.innerHTML = "";
 | 
			
		||||
  fonts.forEach(font => {
 | 
			
		||||
    listDiv.innerHTML += `
 | 
			
		||||
      <div class="font-item">
 | 
			
		||||
        <span>${font}</span>
 | 
			
		||||
        <button type="button" class="remove-font-btn danger" data-font="${font}">Remove</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("DOMContentLoaded", async () => {
 | 
			
		||||
  const themeInfo = await fetchThemeInfo();
 | 
			
		||||
  const themeNameSpan = document.getElementById("current-theme");
 | 
			
		||||
  if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
 | 
			
		||||
 | 
			
		||||
  const themeYaml = themeInfo.theme_yaml;
 | 
			
		||||
  const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
 | 
			
		||||
  let localFonts = await fetchLocalFonts(themeInfo.theme_name);
 | 
			
		||||
 | 
			
		||||
  // Colors
 | 
			
		||||
  if (themeYaml.colors) {
 | 
			
		||||
    setColorInput("color-primary", "color-primary-text", themeYaml.colors.primary || "#0065a1");
 | 
			
		||||
    setColorInput("color-primary-dark", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
 | 
			
		||||
    setColorInput("color-secondary", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
 | 
			
		||||
    setColorInput("color-accent", "color-accent-text", themeYaml.colors.accent || "#ffc700");
 | 
			
		||||
    setColorInput("color-text-dark", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
 | 
			
		||||
    setColorInput("color-background", "color-background-text", themeYaml.colors.background || "#fff");
 | 
			
		||||
    setColorInput("color-browser-color", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Fonts
 | 
			
		||||
  function refreshFontDropdowns() {
 | 
			
		||||
    setFontDropdown("font-primary", document.getElementById("font-primary").value, [
 | 
			
		||||
      ...googleFonts.map(f => f.family),
 | 
			
		||||
      ...localFonts
 | 
			
		||||
    ]);
 | 
			
		||||
    setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
 | 
			
		||||
      ...googleFonts.map(f => f.family),
 | 
			
		||||
      ...localFonts
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
  if (themeYaml.fonts) {
 | 
			
		||||
    setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
 | 
			
		||||
      ...googleFonts.map(f => f.family),
 | 
			
		||||
      ...localFonts
 | 
			
		||||
    ]);
 | 
			
		||||
    setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
 | 
			
		||||
    setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
 | 
			
		||||
      ...googleFonts.map(f => f.family),
 | 
			
		||||
      ...localFonts
 | 
			
		||||
    ]);
 | 
			
		||||
    setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Font upload logic
 | 
			
		||||
  const fontUploadInput = document.getElementById("font-upload");
 | 
			
		||||
  const chooseFontBtn = document.getElementById("choose-font-btn");
 | 
			
		||||
  const fontUploadStatus = document.getElementById("font-upload-status");
 | 
			
		||||
  const localFontsList = document.getElementById("local-fonts-list");
 | 
			
		||||
 | 
			
		||||
  // Modal logic for font deletion
 | 
			
		||||
  const deleteFontModal = document.getElementById("delete-font-modal");
 | 
			
		||||
  const deleteFontModalClose = document.getElementById("delete-font-modal-close");
 | 
			
		||||
  const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm");
 | 
			
		||||
  const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel");
 | 
			
		||||
  let fontToDelete = null;
 | 
			
		||||
 | 
			
		||||
  function refreshLocalFonts() {
 | 
			
		||||
    renderLocalFonts(localFonts);
 | 
			
		||||
    refreshFontDropdowns();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (chooseFontBtn && fontUploadInput) {
 | 
			
		||||
    chooseFontBtn.addEventListener("click", () => fontUploadInput.click());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (fontUploadInput) {
 | 
			
		||||
    fontUploadInput.addEventListener("change", async (e) => {
 | 
			
		||||
      const file = e.target.files[0];
 | 
			
		||||
      if (!file) return;
 | 
			
		||||
      const ext = file.name.split('.').pop().toLowerCase();
 | 
			
		||||
      if (!["woff", "woff2"].includes(ext)) {
 | 
			
		||||
        fontUploadStatus.textContent = "Only .woff and .woff2 fonts are allowed.";
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      formData.append("file", file);
 | 
			
		||||
      formData.append("theme", themeInfo.theme_name);
 | 
			
		||||
      const res = await fetch("/api/font/upload", { method: "POST", body: formData });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        fontUploadStatus.textContent = "Font uploaded!";
 | 
			
		||||
        showToast("Font uploaded!", "success");
 | 
			
		||||
        localFonts = await fetchLocalFonts(themeInfo.theme_name);
 | 
			
		||||
        refreshLocalFonts();
 | 
			
		||||
      } else {
 | 
			
		||||
        fontUploadStatus.textContent = "Error uploading font.";
 | 
			
		||||
        showToast("Error uploading font.", "error");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Remove font button triggers modal
 | 
			
		||||
  if (localFontsList) {
 | 
			
		||||
    localFontsList.addEventListener("click", (e) => {
 | 
			
		||||
      if (e.target.classList.contains("remove-font-btn")) {
 | 
			
		||||
        fontToDelete = e.target.dataset.font;
 | 
			
		||||
        document.getElementById("delete-font-modal-text").textContent =
 | 
			
		||||
          `Are you sure you want to remove the font "${fontToDelete}"?`;
 | 
			
		||||
        deleteFontModal.style.display = "flex";
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Modal logic for font deletion
 | 
			
		||||
  if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) {
 | 
			
		||||
    deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => {
 | 
			
		||||
      deleteFontModal.style.display = "none";
 | 
			
		||||
      fontToDelete = null;
 | 
			
		||||
    };
 | 
			
		||||
    window.onclick = function(event) {
 | 
			
		||||
      if (event.target === deleteFontModal) {
 | 
			
		||||
        deleteFontModal.style.display = "none";
 | 
			
		||||
        fontToDelete = null;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    deleteFontModalConfirm.onclick = async () => {
 | 
			
		||||
      if (!fontToDelete) return;
 | 
			
		||||
      const result = await removeFont(themeInfo.theme_name, fontToDelete);
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        showToast("Font removed!", "success");
 | 
			
		||||
        localFonts = await fetchLocalFonts(themeInfo.theme_name);
 | 
			
		||||
        refreshLocalFonts();
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error removing font.", "error");
 | 
			
		||||
      }
 | 
			
		||||
      deleteFontModal.style.display = "none";
 | 
			
		||||
      fontToDelete = null;
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Initial render of local fonts
 | 
			
		||||
  refreshLocalFonts();
 | 
			
		||||
 | 
			
		||||
  // Favicon logic
 | 
			
		||||
  const faviconInput = document.getElementById("favicon-path");
 | 
			
		||||
  const faviconUpload = document.getElementById("favicon-upload");
 | 
			
		||||
  const chooseFaviconBtn = document.getElementById("choose-favicon-btn");
 | 
			
		||||
  const faviconPreview = document.getElementById("favicon-preview");
 | 
			
		||||
  const removeFaviconBtn = document.getElementById("remove-favicon-btn");
 | 
			
		||||
  const deleteFaviconModal = document.getElementById("delete-favicon-modal");
 | 
			
		||||
  const deleteFaviconModalClose = document.getElementById("delete-favicon-modal-close");
 | 
			
		||||
  const deleteFaviconModalConfirm = document.getElementById("delete-favicon-modal-confirm");
 | 
			
		||||
  const deleteFaviconModalCancel = document.getElementById("delete-favicon-modal-cancel");
 | 
			
		||||
 | 
			
		||||
  function updateFaviconPreview(src) {
 | 
			
		||||
    if (faviconPreview) {
 | 
			
		||||
      faviconPreview.src = src || "";
 | 
			
		||||
      faviconPreview.style.display = src ? "inline-block" : "none";
 | 
			
		||||
    }
 | 
			
		||||
    if (removeFaviconBtn) {
 | 
			
		||||
      removeFaviconBtn.style.display = src ? "inline-block" : "none";
 | 
			
		||||
    }
 | 
			
		||||
    if (chooseFaviconBtn) {
 | 
			
		||||
      chooseFaviconBtn.style.display = src ? "none" : "inline-block";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (chooseFaviconBtn && faviconUpload) {
 | 
			
		||||
    chooseFaviconBtn.addEventListener("click", () => faviconUpload.click());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (faviconUpload) {
 | 
			
		||||
    faviconUpload.addEventListener("change", async (e) => {
 | 
			
		||||
      const file = e.target.files[0];
 | 
			
		||||
      if (!file) return;
 | 
			
		||||
      const ext = file.name.split('.').pop().toLowerCase();
 | 
			
		||||
      if (!["png", "jpg", "jpeg", "ico"].includes(ext)) {
 | 
			
		||||
        showToast("Invalid file type for favicon.", "error");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      formData.append("file", file);
 | 
			
		||||
      formData.append("theme", themeInfo.theme_name);
 | 
			
		||||
      const res = await fetch("/api/favicon/upload", { method: "POST", body: formData });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        faviconInput.value = result.filename;
 | 
			
		||||
        updateFaviconPreview(`/themes/${themeInfo.theme_name}/${result.filename}?t=${Date.now()}`);
 | 
			
		||||
        showToast("Favicon uploaded!", "success");
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error uploading favicon", "error");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (removeFaviconBtn) {
 | 
			
		||||
    removeFaviconBtn.addEventListener("click", () => {
 | 
			
		||||
      deleteFaviconModal.style.display = "flex";
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (deleteFaviconModal && deleteFaviconModalClose && deleteFaviconModalConfirm && deleteFaviconModalCancel) {
 | 
			
		||||
    deleteFaviconModalClose.onclick = deleteFaviconModalCancel.onclick = () => {
 | 
			
		||||
      deleteFaviconModal.style.display = "none";
 | 
			
		||||
    };
 | 
			
		||||
    window.onclick = function(event) {
 | 
			
		||||
      if (event.target === deleteFaviconModal) {
 | 
			
		||||
        deleteFaviconModal.style.display = "none";
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    deleteFaviconModalConfirm.onclick = async () => {
 | 
			
		||||
      const res = await fetch("/api/favicon/remove", {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        headers: { "Content-Type": "application/json" },
 | 
			
		||||
        body: JSON.stringify({ theme: themeInfo.theme_name })
 | 
			
		||||
      });
 | 
			
		||||
      const result = await res.json();
 | 
			
		||||
      if (result.status === "ok") {
 | 
			
		||||
        faviconInput.value = "";
 | 
			
		||||
        updateFaviconPreview("");
 | 
			
		||||
        showToast("Favicon removed!", "success");
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast("Error removing favicon", "error");
 | 
			
		||||
      }
 | 
			
		||||
      deleteFaviconModal.style.display = "none";
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (themeYaml.favicon && themeYaml.favicon.path) {
 | 
			
		||||
    faviconInput.value = themeYaml.favicon.path;
 | 
			
		||||
    updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
 | 
			
		||||
  } else {
 | 
			
		||||
    updateFaviconPreview("");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Google Fonts
 | 
			
		||||
  renderGoogleFonts(googleFonts);
 | 
			
		||||
 | 
			
		||||
  // Add Google Font
 | 
			
		||||
  const addGoogleFontBtn = document.getElementById("add-google-font");
 | 
			
		||||
  if (addGoogleFontBtn) {
 | 
			
		||||
    addGoogleFontBtn.addEventListener("click", () => {
 | 
			
		||||
      googleFonts.push({ family: "", weights: [] });
 | 
			
		||||
      renderGoogleFonts(googleFonts);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Remove Google Font
 | 
			
		||||
  const googleFontsFields = document.getElementById("google-fonts-fields");
 | 
			
		||||
  if (googleFontsFields) {
 | 
			
		||||
    googleFontsFields.addEventListener("click", (e) => {
 | 
			
		||||
      if (e.target.classList.contains("remove-google-font")) {
 | 
			
		||||
        const idx = parseInt(e.target.dataset.idx, 10);
 | 
			
		||||
        googleFonts.splice(idx, 1);
 | 
			
		||||
        renderGoogleFonts(googleFonts);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Form submit
 | 
			
		||||
  document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    const data = {};
 | 
			
		||||
    data.colors = {
 | 
			
		||||
      primary: document.getElementById("color-primary-text").value,
 | 
			
		||||
      primary_dark: document.getElementById("color-primary-dark-text").value,
 | 
			
		||||
      secondary: document.getElementById("color-secondary-text").value,
 | 
			
		||||
      accent: document.getElementById("color-accent-text").value,
 | 
			
		||||
      text_dark: document.getElementById("color-text-dark-text").value,
 | 
			
		||||
      background: document.getElementById("color-background-text").value,
 | 
			
		||||
      browser_color: document.getElementById("color-browser-color-text").value
 | 
			
		||||
    };
 | 
			
		||||
    data.fonts = {
 | 
			
		||||
      primary: {
 | 
			
		||||
        name: document.getElementById("font-primary").value,
 | 
			
		||||
        fallback: document.getElementById("font-primary-fallback").value
 | 
			
		||||
      },
 | 
			
		||||
      secondary: {
 | 
			
		||||
        name: document.getElementById("font-secondary").value,
 | 
			
		||||
        fallback: document.getElementById("font-secondary-fallback").value
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    data.favicon = {
 | 
			
		||||
      path: faviconInput.value
 | 
			
		||||
    };
 | 
			
		||||
    data.google_fonts = [];
 | 
			
		||||
    document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
 | 
			
		||||
      const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
 | 
			
		||||
      const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
 | 
			
		||||
        .split(",").map(w => w.trim()).filter(w => w);
 | 
			
		||||
      if (family) data.google_fonts.push({ family, weights });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const res = await fetch("/api/theme-info", {
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: { "Content-Type": "application/json" },
 | 
			
		||||
      body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
 | 
			
		||||
    });
 | 
			
		||||
    if (res.ok) {
 | 
			
		||||
      showToast("Theme saved!", "success");
 | 
			
		||||
    } else {
 | 
			
		||||
      showToast("Error saving theme.", "error");
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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 = ''; }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										178
									
								
								src/webui/site-info/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/webui/site-info/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <title>Lumeex</title>
 | 
			
		||||
  <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">Site info</a></li>
 | 
			
		||||
          <li class="nav-item appear2"><a href="#">Theme info</a></li>
 | 
			
		||||
          <li class="nav-item appear2"><a href="#">Gallery</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <!-- Toast container for notifications -->
 | 
			
		||||
  <div id="site-info" class="content-inner">
 | 
			
		||||
    <div id="toast-container"></div>
 | 
			
		||||
    <h1>Edit Site Info</h1>
 | 
			
		||||
    <form id="site-info-form">
 | 
			
		||||
      <!-- Info Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Info</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Title</label>
 | 
			
		||||
            <input type="text" name="info.title" placeholder="Your site title" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Subtitle</label>
 | 
			
		||||
            <input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Description</label>
 | 
			
		||||
            <input type="text" name="info.description" placeholder="Your site description" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Canonical URL</label>
 | 
			
		||||
            <input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Keywords (comma separated)</label>
 | 
			
		||||
            <input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Author</label>
 | 
			
		||||
            <input type="text" name="info.author" placeholder="Your Name" required>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Social Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Social</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Instagram URL</label>
 | 
			
		||||
            <input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
 | 
			
		||||
            <label class="thumbnail-form-label">Thumbnail</label>
 | 
			
		||||
            <input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;" required>
 | 
			
		||||
            <button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
 | 
			
		||||
            <div class="thumbnail-form">
 | 
			
		||||
            <img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
 | 
			
		||||
            <button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Menu Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Menu</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field" style="flex: 1 1 100%;">
 | 
			
		||||
            <div id="menu-items-list"></div>
 | 
			
		||||
            <button type="button" id="add-menu-item">+ Add menu item</button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Footer Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Footer</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Copyright</label>
 | 
			
		||||
            <input type="text" name="footer.copyright" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Legal Label</label>
 | 
			
		||||
            <input type="text" name="footer.legal_label" re>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Legals Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Legals</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Hoster Name</label>
 | 
			
		||||
            <input type="text" name="legals.hoster_name" placeholder="Name">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Hoster Address</label>
 | 
			
		||||
            <input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Hoster Contact</label>
 | 
			
		||||
            <input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field" style="flex: 1 1 100%;">
 | 
			
		||||
            <label>Intellectual Property</label>
 | 
			
		||||
            <div id="ip-list"></div>
 | 
			
		||||
            <button type="button" id="add-ip-paragraph">+ Add paragraph</button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Build Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Build</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Theme</label>
 | 
			
		||||
            <select name="build.theme" id="theme-select" required></select>
 | 
			
		||||
            <input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
 | 
			
		||||
            <button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
 | 
			
		||||
            <label class="thumbnail-form-label">Images processing</label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
 | 
			
		||||
              Convert images
 | 
			
		||||
            </label>
 | 
			
		||||
            <label>
 | 
			
		||||
              <input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
 | 
			
		||||
              Resize images
 | 
			
		||||
            </label>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <button type="submit">Save</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
<!-- Delete confirmation modal (now outside .content-inner) -->
 | 
			
		||||
  <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 remove this thumbnail?</p>
 | 
			
		||||
      <div class="modal-actions">
 | 
			
		||||
        <button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
 | 
			
		||||
        <button id="delete-modal-cancel" class="modal-btn">Cancel</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
<script src="{{ url_for('static', filename='js/site-info.js')}}"></script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										664
									
								
								src/webui/style/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										664
									
								
								src/webui/style/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,664 @@
 | 
			
		||||
/* --- Base Styles --- */
 | 
			
		||||
body {
 | 
			
		||||
  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
 | 
			
		||||
  margin: 20px;
 | 
			
		||||
  background: #111010;
 | 
			
		||||
  /* 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;
 | 
			
		||||
}
 | 
			
		||||
h2 {
 | 
			
		||||
  color: #55c3ec;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Toolbar --- */
 | 
			
		||||
.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 --- */
 | 
			
		||||
.upload-section {
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
  background-color: rgb(67 67 67 / 26%);
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  border: 1px solid #2f2e2e80;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 0px 20px 20px 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.upload-section label {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Gallery & Hero Grid --- */
 | 
			
		||||
#gallery, #hero {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
  margin-top: 30px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Photo Card --- */
 | 
			
		||||
.photo {
 | 
			
		||||
  background-color: rgb(67 67 67 / 26%);
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  border: 1px solid #2f2e2e80;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.photo img {
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.photo input[type="text"] {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 4px 6px;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  color: rgb(221, 221, 221);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.photo button {
 | 
			
		||||
  padding: 4px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  background-color:rgb(121 26 19);
 | 
			
		||||
  color: white;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background-color 0.2s;
 | 
			
		||||
  margin: 5px 4px 0 4px;
 | 
			
		||||
  width:100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.photo button:hover {
 | 
			
		||||
  background-color: #d32f2f;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- 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;
 | 
			
		||||
  bottom: 1rem;
 | 
			
		||||
  right: 1rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.toast {
 | 
			
		||||
  background: rgba(0,0,0,0.85);
 | 
			
		||||
  color: white;
 | 
			
		||||
  padding: 0.75rem 1.25rem;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transform: translateY(20px);
 | 
			
		||||
  transition: opacity 0.5s ease, transform 0.5s ease;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  backdrop-filter: blur(20px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.toast.show {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transform: translateY(0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.toast.success { background-color: #28a7468c; }
 | 
			
		||||
.toast.error { background-color: #dc3545; }
 | 
			
		||||
 | 
			
		||||
/* --- Tags --- */
 | 
			
		||||
.tag-input {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  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;
 | 
			
		||||
  display: none;
 | 
			
		||||
  box-shadow: 0 4px 8px rgba(0,0,0,0.15);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tag-input ul.suggestions li {
 | 
			
		||||
  padding: 6px 8px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tag-input ul.suggestions li:hover {
 | 
			
		||||
  background-color: #007782;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tags-display {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 4px;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.suggestions li.selected {
 | 
			
		||||
  background-color: #007782;
 | 
			
		||||
  color: white;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
.suggestions li {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Flex Utilities --- */
 | 
			
		||||
.flex-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-column {
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-full {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  flex-direction: column-reverse;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-end {
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
  width: 100%
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Top Bar & Navigation --- */
 | 
			
		||||
.nav {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  max-width: 1140px;
 | 
			
		||||
  padding: 0 40px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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-links > .nav-list > .nav-item > a:active {
 | 
			
		||||
  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 --- */
 | 
			
		||||
.up-btn {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  background: #00000000;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  padding: 0.5em 1em;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  transition: all 0.1s ease;
 | 
			
		||||
  user-select: none;
 | 
			
		||||
  /* box-shadow: 0 4px 10px rgba(0,0,0,0.25);*/
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  border: 1px solid #585858;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.up-btn:hover {
 | 
			
		||||
  background: #2d2d2d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Modal Styles --- */
 | 
			
		||||
.modal {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 0; left: 0; right: 0; bottom: 0;
 | 
			
		||||
  z-index: 10000;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-content {
 | 
			
		||||
  background: #131313;
 | 
			
		||||
  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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-close {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 12px; right: 18px;
 | 
			
		||||
  font-size: 1.5rem;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  opacity: 0.7;
 | 
			
		||||
}
 | 
			
		||||
.modal-close:hover { opacity: 1; }
 | 
			
		||||
 | 
			
		||||
.modal-actions {
 | 
			
		||||
  margin-top: 2rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-btn {
 | 
			
		||||
  padding: 0.5em 1.5em;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  background: #09A0C1;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-btn.danger {
 | 
			
		||||
  background: #c62828;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-btn:hover {
 | 
			
		||||
  background: #55c3ec;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-btn.danger:hover {
 | 
			
		||||
  background: #d32f2f;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Upload Actions Row --- */
 | 
			
		||||
.upload-actions-row {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 16px;
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Remove All Buttons */
 | 
			
		||||
#remove-all-hero, #remove-all-gallery {
 | 
			
		||||
  background: #2d2d2d;
 | 
			
		||||
  color: white;
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#remove-all-gallery:hover,
 | 
			
		||||
#remove-all-hero:hover {
 | 
			
		||||
  background: rgb(121, 26, 19);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive: stack buttons vertically on small screens */
 | 
			
		||||
@media (max-width: 500px) {
 | 
			
		||||
  .upload-actions-row {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    align-items: stretch;
 | 
			
		||||
    gap: 8px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* --- Site Info --- */
 | 
			
		||||
 | 
			
		||||
/* --- Site Info Form --- */
 | 
			
		||||
 | 
			
		||||
#site-info.content-inner {
 | 
			
		||||
  margin-right: auto;
 | 
			
		||||
  margin-left: auto;
 | 
			
		||||
  max-width: 1140px;
 | 
			
		||||
  padding: 0 40px 40px 40px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fieldset {
 | 
			
		||||
  background-color: rgb(67 67 67 / 26%);
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  border: 1px solid #2f2e2e80;
 | 
			
		||||
  margin-bottom: 28px;
 | 
			
		||||
  padding: 0 28px 32px 28px;
 | 
			
		||||
  margin: 32px auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
legend {
 | 
			
		||||
  font-size: 1.2em;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  color: #26c4ff;
 | 
			
		||||
  margin-bottom: 12px;
 | 
			
		||||
  letter-spacing: 1px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fields {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 18px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.input-field {
 | 
			
		||||
  flex: 1 1 calc(33.333% - 18px);
 | 
			
		||||
  min-width: 220px;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
label {
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  color: #e3e3e3;
 | 
			
		||||
  margin-bottom: 6px;
 | 
			
		||||
  letter-spacing: 0.5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form input, #theme-editor-form input,
 | 
			
		||||
#site-info-form textarea, #theme-editor-form textarea,
 | 
			
		||||
#site-info-form select, #theme-editor-form select {
 | 
			
		||||
  /* background: rgba(4, 44, 60, 0.55);*/
 | 
			
		||||
  background: #1f2223;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: 1px solid #585858;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  padding: 10px 14px;
 | 
			
		||||
  margin-bottom: 4px;
 | 
			
		||||
  outline: none;
 | 
			
		||||
  transition: border-color 0.2s, background 0.2s;
 | 
			
		||||
  box-shadow: 0 2px 8px rgba(0,0,0,0.07);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form input::placeholder,
 | 
			
		||||
#theme-editor-form input::placeholder,
 | 
			
		||||
#site-info-form textarea::placeholder,
 | 
			
		||||
#theme-editor-form textarea::placeholder {
 | 
			
		||||
  color: #585858;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form input:focus,
 | 
			
		||||
#theme-editor-form input:focus,
 | 
			
		||||
#site-info-form textarea:focus,
 | 
			
		||||
#theme-editor-form textarea:focus,
 | 
			
		||||
#site-info-form select:focus,
 | 
			
		||||
#theme-editor-form select:focus {
 | 
			
		||||
  border-color: #585858;
 | 
			
		||||
  background: #161616;
 | 
			
		||||
}
 | 
			
		||||
#site-info-form textarea,
 | 
			
		||||
#theme-editor-form textarea {
 | 
			
		||||
  min-height: 60px;
 | 
			
		||||
  resize: vertical;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#input[type="file"] {
 | 
			
		||||
  background: none;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: none;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin-top: 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
img#thumbnail-preview {
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  border: 1px solid #585858;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
 | 
			
		||||
  background: linear-gradient(135deg, #26c4ff, #016074);
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  padding: 12px 32px;
 | 
			
		||||
  font-size: 1.1em;
 | 
			
		||||
  margin-top: 18px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  box-shadow: 0 4px 16px rgba(38,196,255,0.15);
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button[type="submit"]:hover,  #theme-editor-form button[type="submit"]:hover {
 | 
			
		||||
  background: linear-gradient(135deg, #72d9ff, #26657e);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
 | 
			
		||||
  background: #00000000;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 18px;
 | 
			
		||||
  padding: 7px 18px;
 | 
			
		||||
  font-size: 0.98em;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
  border: 1px solid #585858;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover {
 | 
			
		||||
  background: #2d2d2d;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 900px) {
 | 
			
		||||
  #site-info-form, #theme-editor-form {
 | 
			
		||||
    padding: 18px 8px;
 | 
			
		||||
  }
 | 
			
		||||
  .fields,
 | 
			
		||||
  fieldset {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 0;
 | 
			
		||||
  }
 | 
			
		||||
  .input-field {
 | 
			
		||||
    min-width: 100%;
 | 
			
		||||
    margin-bottom: 12px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph {
 | 
			
		||||
  margin-top: 0px;
 | 
			
		||||
  margin-bottom: 4px;
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  background: #2d2d2d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
 | 
			
		||||
  background: rgb(121, 26, 19);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
 | 
			
		||||
 | 
			
		||||
  border-radius: 30px;
 | 
			
		||||
  background: #2d2d2d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover {
 | 
			
		||||
  background: rgb(121, 26, 19);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										178
									
								
								src/webui/theme-editor/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/webui/theme-editor/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <title>Theme Editor</title>
 | 
			
		||||
  <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="/site-info">
 | 
			
		||||
          <span id="step">← Back to Site Info</span>
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
      <input type="checkbox" id="nav-check">
 | 
			
		||||
      <div class="nav-header">
 | 
			
		||||
        <div class="nav-title">
 | 
			
		||||
          <img src="../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">Site info</a></li>
 | 
			
		||||
          <li class="nav-item appear2"><a href="/theme-editor">Theme editor</a></li>
 | 
			
		||||
          <li class="nav-item appear2"><a href="#">Gallery</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <!-- Toast container for notifications -->
 | 
			
		||||
  <div id="theme-editor" class="content-inner">
 | 
			
		||||
    <div id="toast-container"></div>
 | 
			
		||||
    <h1>Edit Theme</h1>
 | 
			
		||||
    <!-- Show current theme -->
 | 
			
		||||
    <div class="theme-info">
 | 
			
		||||
      <strong>Current theme:</strong> <span id="current-theme"></span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <form id="theme-editor-form">
 | 
			
		||||
      <!-- Colors Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Colors</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Primary</label>
 | 
			
		||||
            <input type="color" name="colors.primary" id="color-primary">
 | 
			
		||||
            <input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Primary Dark</label>
 | 
			
		||||
            <input type="color" name="colors.primary_dark" id="color-primary-dark">
 | 
			
		||||
            <input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Secondary</label>
 | 
			
		||||
            <input type="color" name="colors.secondary" id="color-secondary">
 | 
			
		||||
            <input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Accent</label>
 | 
			
		||||
            <input type="color" name="colors.accent" id="color-accent">
 | 
			
		||||
            <input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Text Dark</label>
 | 
			
		||||
            <input type="color" name="colors.text_dark" id="color-text-dark">
 | 
			
		||||
            <input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Background</label>
 | 
			
		||||
            <input type="color" name="colors.background" id="color-background">
 | 
			
		||||
            <input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Browser Color</label>
 | 
			
		||||
            <input type="color" name="colors.browser_color" id="color-browser-color">
 | 
			
		||||
            <input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Google Fonts Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Google Fonts</h2>
 | 
			
		||||
        <div class="fields" id="google-fonts-fields">
 | 
			
		||||
          <!-- JS will render font family and weights inputs here -->
 | 
			
		||||
        </div>
 | 
			
		||||
        <button type="button" id="add-google-font">Add Google Font</button>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Custom Font Upload Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Upload Custom Font (.woff, .woff2)</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
 | 
			
		||||
          <button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
 | 
			
		||||
          <div id="local-fonts-list" class="font-list"></div>
 | 
			
		||||
          <span id="font-upload-status"></span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Fonts Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Fonts</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Primary Font</label>
 | 
			
		||||
            <select name="fonts.primary.name" id="font-primary"></select>
 | 
			
		||||
            <label>Fallback</label>
 | 
			
		||||
            <select name="fonts.primary.fallback" id="font-primary-fallback">
 | 
			
		||||
              <option value="sans-serif">sans-serif</option>
 | 
			
		||||
              <option value="serif">serif</option>
 | 
			
		||||
            </select>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Secondary Font</label>
 | 
			
		||||
            <select name="fonts.secondary.name" id="font-secondary"></select>
 | 
			
		||||
            <label>Fallback</label>
 | 
			
		||||
            <select name="fonts.secondary.fallback" id="font-secondary-fallback">
 | 
			
		||||
              <option value="sans-serif">sans-serif</option>
 | 
			
		||||
              <option value="serif">serif</option>
 | 
			
		||||
            </select>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <!-- Favicon Section -->
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <h2>Favicon</h2>
 | 
			
		||||
        <div class="fields">
 | 
			
		||||
          <div class="input-field">
 | 
			
		||||
            <label>Favicon Path</label>
 | 
			
		||||
            <input type="text" name="favicon.path" id="favicon-path" readonly>
 | 
			
		||||
            <input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
 | 
			
		||||
            <button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
 | 
			
		||||
            <div class="favicon-form">
 | 
			
		||||
              <img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
 | 
			
		||||
              <button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <button type="submit">Save Theme</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
  <!-- Delete confirmation modal for favicon -->
 | 
			
		||||
  <div id="delete-favicon-modal" class="modal" style="display:none;">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <span id="delete-favicon-modal-close" class="modal-close">×</span>
 | 
			
		||||
      <h3>Confirm Deletion</h3>
 | 
			
		||||
      <p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
 | 
			
		||||
      <div class="modal-actions">
 | 
			
		||||
        <button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
 | 
			
		||||
        <button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <!-- Delete confirmation modal for font -->
 | 
			
		||||
  <div id="delete-font-modal" class="modal" style="display:none;">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <span id="delete-font-modal-close" class="modal-close">×</span>
 | 
			
		||||
      <h3>Confirm Deletion</h3>
 | 
			
		||||
      <p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
 | 
			
		||||
      <div class="modal-actions">
 | 
			
		||||
        <button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
 | 
			
		||||
        <button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
		Reference in New Issue
	
	Block a user